diff --git a/.changeset/add-howto-jsonld.md b/.changeset/add-howto-jsonld.md new file mode 100644 index 00000000..bca83806 --- /dev/null +++ b/.changeset/add-howto-jsonld.md @@ -0,0 +1,13 @@ +--- +"next-seo": minor +--- + +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 diff --git a/README.md b/README.md index a6f178f8..903e97ae 100644 --- a/README.md +++ b/README.md @@ -631,6 +631,268 @@ Use these formats for time durations: [↑ Back to Components](#-components-by-category) +### HowToJsonLd + +The `HowToJsonLd` component helps you add structured data for how-to guides and tutorials. This can help your content appear as rich results with step-by-step instructions in search results. + +#### Basic Usage + +```tsx +import { HowToJsonLd } from "next-seo"; + +export default function HowToPage() { + return ( + <> + +
+

How to Change a Flat Tire

+ {/* Guide content */} +
+ + ); +} +``` + +#### Advanced Example with Sections and Detailed Steps + +```tsx + +``` + +#### Props + +| Property | Type | Description | +| --------------- | ---------------------------------------------------- | ------------------------------------------------------------------------- | +| `name` | `string` | **Required.** The title of the how-to guide | +| `description` | `string` | A brief description of the guide | +| `image` | `string \| ImageObject` | An image of the completed task or project | +| `estimatedCost` | `string \| MonetaryAmount` | The estimated cost of supplies (e.g., "$20" or MonetaryAmount object) | +| `prepTime` | `string` | ISO 8601 duration for preparation time | +| `performTime` | `string` | ISO 8601 duration for the time to perform the instructions | +| `totalTime` | `string` | ISO 8601 duration for total time (prep + perform) | +| `yield` | `string \| QuantitativeValue` | The result of performing the instructions (e.g., "1 birdhouse") | +| `supply` | `string \| HowToSupply \| (string \| HowToSupply)[]` | Supplies consumed when performing the task | +| `tool` | `string \| HowToTool \| (string \| HowToTool)[]` | Tools used but not consumed | +| `step` | `string \| HowToStep \| HowToSection \| (Step)[]` | The steps to complete the task. Can be simple strings, steps, or sections | +| `video` | `VideoObject` | A video showing how to complete the task | +| `scriptId` | `string` | Custom ID for the script tag | +| `scriptKey` | `string` | Custom key prop for React | + +#### Step Types + +**HowToStep** - A single step in the guide: + +```tsx +{ + "@type": "HowToStep", + name: "Step Name", // Optional step title + text: "Step instructions", // The instruction text + url: "https://...", // Optional URL for more details + image: "https://...", // Optional step image +} +``` + +**HowToSection** - A group of related steps: + +```tsx +{ + "@type": "HowToSection", + name: "Section Name", + position: 1, + itemListElement: [ + { "@type": "HowToStep", text: "First step" }, + { "@type": "HowToStep", text: "Second step" }, + ] +} +``` + +**HowToDirection** and **HowToTip** - For detailed step content: + +```tsx +{ + "@type": "HowToStep", + itemListElement: [ + { + "@type": "HowToDirection", + text: "Do this specific action", + beforeMedia: "https://example.com/before.jpg", + afterMedia: "https://example.com/after.jpg", + }, + { + "@type": "HowToTip", + text: "Here's a helpful tip", + } + ] +} +``` + +#### Duration Format (ISO 8601) + +Use these formats for time durations: + +- `PT15M` - 15 minutes +- `PT1H` - 1 hour +- `PT1H30M` - 1 hour 30 minutes +- `PT2H15M` - 2 hours 15 minutes + +#### Best Practices + +1. **Clear steps**: Write concise, actionable step instructions +2. **Include images**: Add images for complex steps to improve clarity +3. **Separate sections**: Use HowToSection to group related steps logically +4. **Accurate timing**: Provide realistic time estimates for each phase +5. **List all materials**: Include all supplies and tools needed upfront +6. **Add video**: Video content significantly improves search appearance + +[↑ Back to Components](#-components-by-category) + ### OrganizationJsonLd The `OrganizationJsonLd` component helps you add structured data about your organization to improve how it appears in search results and knowledge panels. diff --git a/examples/app-router-showcase/app/howto-advanced/page.tsx b/examples/app-router-showcase/app/howto-advanced/page.tsx new file mode 100644 index 00000000..3eb6a9c8 --- /dev/null +++ b/examples/app-router-showcase/app/howto-advanced/page.tsx @@ -0,0 +1,261 @@ +import { HowToJsonLd } from "next-seo"; + +export default function HowToAdvancedPage() { + return ( +
+ + +
+

How to Change a Flat Tire

+

+ A comprehensive guide with detailed sections and helpful tips +

+ +
+

Estimated Time: 30 minutes

+

Preparation: 5 minutes | Performing: 25 minutes

+
+ +

Section 1: Preparation

+
    +
  1. + Set up safety signals: Turn on your hazard lights + and set out flares or reflective triangles. +
    + 💡 Tip: You need space and want to be visible to other drivers. +
    +
  2. +
  3. + Secure the vehicle: Position wheel wedges in front + of front tires (for rear flat) or behind rear tires (for front + flat). +
    + 💡 Tip: This prevents the car from moving while you work. +
    +
  4. +
+ +

Section 2: Raise the Car

+
    +
  1. + Position the jack: Place it underneath the car, + next to the flat tire. +
  2. +
  3. + Raise the vehicle: Lift until the flat tire is + barely off the ground. +
    + 💡 Tip: It doesn't need to be too high - just enough to clear the + ground. +
    +
  4. +
  5. + Remove hubcap: Take off the hubcap and loosen the + lug nuts. +
  6. +
  7. + Swap the tire: Remove the flat and mount the spare + on the lug bolts. +
  8. +
  9. + Hand-tighten: Tighten the lug nuts by hand first. +
    + 💡 Tip: Don't use the wrench yet - wait until the car is lowered. +
    +
  10. +
+ +

Section 3: Finishing Up

+
    +
  1. + Lower and tighten: Lower the jack and use the + wrench to fully tighten lug nuts. +
  2. +
  3. + Replace hubcap: Put the hubcap back on if your + spare uses one. +
  4. +
  5. + Store equipment: Put away the jack, wrench, and + flat tire. +
  6. +
+ +
+

Video Tutorial

+

+ Watch our 8-minute video tutorial to see a professional mechanic + demonstrate the proper technique. +

+
+
+
+ ); +} diff --git a/examples/app-router-showcase/app/howto/page.tsx b/examples/app-router-showcase/app/howto/page.tsx new file mode 100644 index 00000000..02f37912 --- /dev/null +++ b/examples/app-router-showcase/app/howto/page.tsx @@ -0,0 +1,130 @@ +import { HowToJsonLd } from "next-seo"; + +export default function HowToPage() { + return ( +
+ + +
+

How to Change a Flat Tire

+

+ A step-by-step guide to safely changing a flat tire +

+ +
+

Safety First

+

+ Always pull over to a safe location away from traffic before + attempting to change a tire. +

+
+ +

What You'll Need

+
+
+

Tools

+
    +
  • Spare tire
  • +
  • Lug wrench
  • +
  • Jack
  • +
  • Wheel wedges
  • +
+
+
+

Supplies

+
    +
  • Flares or reflective triangles
  • +
+
+
+ +

Time Required

+

+ Preparation: 5 minutes +
+ Performing the task: 25 minutes +
+ Total time: 30 minutes +

+ +

Estimated Cost

+

$20 (for flares and other emergency supplies)

+ +

Step-by-Step Instructions

+
    +
  1. + Secure the vehicle: Turn on your hazard lights and + apply the parking brake. +
  2. +
  3. + Prevent rolling: Apply wheel wedges behind the + tires to prevent the vehicle from moving. +
  4. +
  5. + Prepare the wheel: Remove the hubcap and loosen the + lug nuts (turn counterclockwise) while the tire is still on the + ground. +
  6. +
  7. + Position the jack: Place the jack under the vehicle + frame near the flat tire. +
  8. +
  9. + Raise the vehicle: Use the jack to raise the + vehicle until the flat tire is about 6 inches off the ground. +
  10. +
  11. + Remove the flat: Remove the lug nuts completely and + pull off the flat tire. +
  12. +
  13. + Mount the spare: Place the spare tire on the lug + bolts and hand-tighten the lug nuts. +
  14. +
  15. + Lower and tighten: Lower the vehicle and fully + tighten the lug nuts in a star pattern. +
  16. +
  17. + Final check: Check the tire pressure and drive to a + service station to have the spare properly inspected. +
  18. +
+ +
+

Pro Tips

+
    +
  • Keep your spare tire inflated and check it periodically
  • +
  • Store a flashlight in your trunk for nighttime emergencies
  • +
  • + Most spare tires are not designed for high speeds - drive under 50 + mph +
  • +
+
+
+
+ ); +} diff --git a/src/components/HowToJsonLd.test.tsx b/src/components/HowToJsonLd.test.tsx new file mode 100644 index 00000000..5d9d175d --- /dev/null +++ b/src/components/HowToJsonLd.test.tsx @@ -0,0 +1,731 @@ +import { render } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import HowToJsonLd from "./HowToJsonLd"; + +describe("HowToJsonLd", () => { + it("renders basic HowTo 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": "HowTo", + name: "How to Change a Tire", + }); + }); + + it("handles string steps", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + const jsonData = JSON.parse(script!.textContent!); + expect(jsonData.step).toEqual(["Step 1: Do this", "Step 2: Do that"]); + }); + + it("handles HowToStep objects", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + const jsonData = JSON.parse(script!.textContent!); + expect(jsonData.step).toEqual([ + { + "@type": "HowToStep", + name: "First Step", + text: "Do the first thing", + url: "https://example.com/step-1", + }, + { + "@type": "HowToStep", + name: "Second Step", + text: "Do the second thing", + }, + ]); + }); + + it("handles HowToStep without @type", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + const jsonData = JSON.parse(script!.textContent!); + expect(jsonData.step[0]["@type"]).toBe("HowToStep"); + expect(jsonData.step[0].name).toBe("First Step"); + expect(jsonData.step[0].text).toBe("Do something"); + }); + + it("handles HowToSection objects", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + const jsonData = JSON.parse(script!.textContent!); + expect(jsonData.step[0]["@type"]).toBe("HowToSection"); + expect(jsonData.step[0].name).toBe("Preparation"); + expect(jsonData.step[0].itemListElement).toHaveLength(2); + }); + + it("handles HowToSection without @type", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + const jsonData = JSON.parse(script!.textContent!); + expect(jsonData.step[0]["@type"]).toBe("HowToSection"); + expect(jsonData.step[0].name).toBe("Setup"); + }); + + it("handles string supplies", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + const jsonData = JSON.parse(script!.textContent!); + expect(jsonData.supply).toEqual([ + { "@type": "HowToSupply", name: "Wood" }, + { "@type": "HowToSupply", name: "Nails" }, + { "@type": "HowToSupply", name: "Paint" }, + ]); + }); + + it("handles HowToSupply objects", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + const jsonData = JSON.parse(script!.textContent!); + expect(jsonData.supply[0]).toEqual({ + "@type": "HowToSupply", + name: "Wood planks", + image: "https://example.com/wood.jpg", + }); + expect(jsonData.supply[1]["@type"]).toBe("HowToSupply"); + expect(jsonData.supply[1].name).toBe("Nails"); + expect(jsonData.supply[1].requiredQuantity).toBe(20); + }); + + it("handles string tools", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + const jsonData = JSON.parse(script!.textContent!); + expect(jsonData.tool).toEqual([ + { "@type": "HowToTool", name: "Hammer" }, + { "@type": "HowToTool", name: "Screwdriver" }, + { "@type": "HowToTool", name: "Drill" }, + ]); + }); + + it("handles HowToTool objects", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + const jsonData = JSON.parse(script!.textContent!); + expect(jsonData.tool[0]).toEqual({ + "@type": "HowToTool", + name: "Lug wrench", + image: "https://example.com/lug-wrench.jpg", + }); + expect(jsonData.tool[1]["@type"]).toBe("HowToTool"); + expect(jsonData.tool[1].name).toBe("Jack"); + }); + + it("handles string estimatedCost", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + const jsonData = JSON.parse(script!.textContent!); + expect(jsonData.estimatedCost).toBe("About $20"); + }); + + it("handles MonetaryAmount estimatedCost", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + const jsonData = JSON.parse(script!.textContent!); + expect(jsonData.estimatedCost).toEqual({ + "@type": "MonetaryAmount", + currency: "USD", + value: 20, + }); + }); + + it("handles MonetaryAmount estimatedCost without @type", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + const jsonData = JSON.parse(script!.textContent!); + expect(jsonData.estimatedCost["@type"]).toBe("MonetaryAmount"); + expect(jsonData.estimatedCost.currency).toBe("USD"); + expect(jsonData.estimatedCost.value).toBe(50); + }); + + it("handles duration properties", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + const jsonData = JSON.parse(script!.textContent!); + expect(jsonData.prepTime).toBe("PT5M"); + expect(jsonData.performTime).toBe("PT25M"); + expect(jsonData.totalTime).toBe("PT30M"); + }); + + it("handles string yield", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + const jsonData = JSON.parse(script!.textContent!); + expect(jsonData.yield).toBe("1 finished birdhouse"); + }); + + it("handles QuantitativeValue yield", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + const jsonData = JSON.parse(script!.textContent!); + expect(jsonData.yield).toEqual({ + "@type": "QuantitativeValue", + value: 10, + unitText: "pieces", + }); + }); + + it("handles QuantitativeValue yield without @type", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + const jsonData = JSON.parse(script!.textContent!); + expect(jsonData.yield["@type"]).toBe("QuantitativeValue"); + expect(jsonData.yield.value).toBe(5); + expect(jsonData.yield.unitText).toBe("items"); + }); + + it("handles description", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + const jsonData = JSON.parse(script!.textContent!); + expect(jsonData.description).toBe( + "Step-by-step instructions for changing a flat tire safely", + ); + }); + + it("handles string image", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + const jsonData = JSON.parse(script!.textContent!); + expect(jsonData.image).toBe("https://example.com/howto.jpg"); + }); + + it("handles ImageObject", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + const jsonData = JSON.parse(script!.textContent!); + expect(jsonData.image).toEqual({ + "@type": "ImageObject", + url: "https://example.com/howto.jpg", + width: 1200, + height: 800, + }); + }); + + it("handles ImageObject without @type", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + const jsonData = JSON.parse(script!.textContent!); + expect(jsonData.image["@type"]).toBe("ImageObject"); + expect(jsonData.image.url).toBe("https://example.com/howto.jpg"); + }); + + it("handles video", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + const jsonData = JSON.parse(script!.textContent!); + expect(jsonData.video).toEqual({ + "@type": "VideoObject", + name: "How to Video", + description: "A video tutorial", + thumbnailUrl: "https://example.com/thumb.jpg", + uploadDate: "2024-01-01T08:00:00+00:00", + contentUrl: "https://example.com/video.mp4", + duration: "PT5M", + }); + }); + + it("handles video without @type", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + const jsonData = JSON.parse(script!.textContent!); + expect(jsonData.video["@type"]).toBe("VideoObject"); + }); + + it("handles single step", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + const jsonData = JSON.parse(script!.textContent!); + expect(jsonData.step).toEqual({ + "@type": "HowToStep", + text: "Just do this one thing", + }); + }); + + it("handles single supply", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + const jsonData = JSON.parse(script!.textContent!); + expect(jsonData.supply).toEqual({ + "@type": "HowToSupply", + name: "Just one item", + }); + }); + + it("handles single tool", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + const jsonData = JSON.parse(script!.textContent!); + expect(jsonData.tool).toEqual({ + "@type": "HowToTool", + name: "Just one tool", + }); + }); + + it("handles all optional properties", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + const jsonData = JSON.parse(script!.textContent!); + + expect(jsonData["@context"]).toBe("https://schema.org"); + expect(jsonData["@type"]).toBe("HowTo"); + expect(jsonData.name).toBe("Complete HowTo Guide"); + expect(jsonData.description).toBe( + "A comprehensive guide with all properties", + ); + expect(jsonData.image).toBe("https://example.com/howto.jpg"); + expect(jsonData.estimatedCost).toBe("$50"); + expect(jsonData.prepTime).toBe("PT10M"); + expect(jsonData.performTime).toBe("PT30M"); + expect(jsonData.totalTime).toBe("PT40M"); + expect(jsonData.yield).toBe("1 completed project"); + expect(jsonData.tool).toHaveLength(2); + expect(jsonData.supply).toHaveLength(2); + expect(jsonData.step).toHaveLength(2); + }); + + it("uses custom scriptId when provided", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + expect(script!.getAttribute("id")).toBe("custom-howto-id"); + expect(script!.getAttribute("data-testid")).toBe("custom-howto-id"); + }); + + it("uses custom scriptKey when provided", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + expect(script).toBeTruthy(); + }); + + it("uses default scriptKey when not provided", () => { + const { container } = render(); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + expect(script).toBeTruthy(); + }); + + it("handles steps with directions and tips", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + const jsonData = JSON.parse(script!.textContent!); + expect(jsonData.step[0].itemListElement).toHaveLength(2); + expect(jsonData.step[0].itemListElement[0]["@type"]).toBe("HowToDirection"); + expect(jsonData.step[0].itemListElement[1]["@type"]).toBe("HowToTip"); + }); + + it("handles directions with media", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + const jsonData = JSON.parse(script!.textContent!); + const direction = jsonData.step[0].itemListElement[0]; + expect(direction.beforeMedia).toBe("https://example.com/before.jpg"); + expect(direction.afterMedia).toBe("https://example.com/after.jpg"); + expect(direction.duringMedia).toBe("https://example.com/during.jpg"); + }); + + it("handles HowToSupply with requiredQuantity as QuantitativeValue", () => { + const { container } = render( + , + ); + + const script = container.querySelector( + 'script[type="application/ld+json"]', + ); + const jsonData = JSON.parse(script!.textContent!); + expect(jsonData.supply[0].requiredQuantity["@type"]).toBe( + "QuantitativeValue", + ); + expect(jsonData.supply[0].requiredQuantity.value).toBe(10); + expect(jsonData.supply[0].requiredQuantity.unitText).toBe("pieces"); + }); +}); diff --git a/src/components/HowToJsonLd.tsx b/src/components/HowToJsonLd.tsx new file mode 100644 index 00000000..71f36cd1 --- /dev/null +++ b/src/components/HowToJsonLd.tsx @@ -0,0 +1,71 @@ +import { JsonLdScript } from "~/core/JsonLdScript"; +import type { HowToJsonLdProps } from "~/types/howto.types"; +import { + processImage, + processVideo, + processStep, + processHowToSupply, + processHowToTool, + processEstimatedCost, + processHowToYield, +} from "~/utils/processors"; + +export default function HowToJsonLd({ + scriptId, + scriptKey, + name, + description, + image, + estimatedCost, + performTime, + prepTime, + totalTime, + step, + supply, + tool, + yield: yieldValue, + video, +}: HowToJsonLdProps) { + const data = { + "@context": "https://schema.org", + "@type": "HowTo", + name, + ...(description && { description }), + ...(image && { + image: Array.isArray(image) + ? image.map(processImage) + : processImage(image), + }), + ...(estimatedCost && { + estimatedCost: processEstimatedCost(estimatedCost), + }), + ...(performTime && { performTime }), + ...(prepTime && { prepTime }), + ...(totalTime && { totalTime }), + ...(step && { + step: Array.isArray(step) ? step.map(processStep) : processStep(step), + }), + ...(supply && { + supply: Array.isArray(supply) + ? supply.map(processHowToSupply) + : processHowToSupply(supply), + }), + ...(tool && { + tool: Array.isArray(tool) + ? tool.map(processHowToTool) + : processHowToTool(tool), + }), + ...(yieldValue && { yield: processHowToYield(yieldValue) }), + ...(video && { video: processVideo(video) }), + }; + + return ( + + ); +} + +export type { HowToJsonLdProps }; diff --git a/src/index.ts b/src/index.ts index e12d232d..a6227351 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,10 @@ export { default as RecipeJsonLd, type RecipeJsonLdProps, } from "./components/RecipeJsonLd"; +export { + default as HowToJsonLd, + type HowToJsonLdProps, +} from "./components/HowToJsonLd"; export { default as OrganizationJsonLd, type OrganizationJsonLdProps, diff --git a/src/types/howto.types.ts b/src/types/howto.types.ts new file mode 100644 index 00000000..18b640d4 --- /dev/null +++ b/src/types/howto.types.ts @@ -0,0 +1,157 @@ +import type { + ImageObject, + QuantitativeValue, + SimpleMonetaryAmount, + VideoObject, +} from "./common.types"; + +/** + * HowToSupply - A supply consumed when performing instructions + * @see https://schema.org/HowToSupply + */ +export interface HowToSupply { + "@type": "HowToSupply"; + name: string; + image?: string | ImageObject | Omit; + requiredQuantity?: + | number + | string + | QuantitativeValue + | Omit; + estimatedCost?: + | string + | SimpleMonetaryAmount + | Omit; +} + +/** + * HowToTool - An object used (but not consumed) when performing instructions + * @see https://schema.org/HowToTool + */ +export interface HowToTool { + "@type": "HowToTool"; + name: string; + image?: string | ImageObject | Omit; + requiredQuantity?: + | number + | string + | QuantitativeValue + | Omit; +} + +/** + * HowToDirection - A direction or instruction within a HowToStep + * @see https://schema.org/HowToDirection + */ +export interface HowToDirection { + "@type": "HowToDirection"; + text: string; + position?: number; + beforeMedia?: string | ImageObject | Omit; + afterMedia?: string | ImageObject | Omit; + duringMedia?: string | ImageObject | Omit; +} + +/** + * HowToTip - A tip or suggestion within a HowToStep + * @see https://schema.org/HowToTip + */ +export interface HowToTip { + "@type": "HowToTip"; + text: string; + position?: number; +} + +/** + * HowToStep - A step in a HowTo guide + * @see https://schema.org/HowToStep + */ +export interface HowToStep { + "@type": "HowToStep"; + name?: string; + text?: string; + url?: string; + image?: string | ImageObject | Omit; + position?: number; + itemListElement?: ( + | HowToDirection + | HowToTip + | Omit + | Omit + )[]; +} + +/** + * HowToSection - A section of steps within a HowTo guide + * @see https://schema.org/HowToSection + */ +export interface HowToSection { + "@type": "HowToSection"; + name: string; + position?: number; + itemListElement: (HowToStep | Omit)[]; +} + +/** + * Step type union - represents all valid step types + */ +export type Step = + | string + | HowToStep + | HowToSection + | Omit + | Omit; + +/** + * Supply type union - flexible input for supplies + */ +export type Supply = string | HowToSupply | Omit; + +/** + * Tool type union - flexible input for tools + */ +export type Tool = string | HowToTool | Omit; + +/** + * EstimatedCost type union - flexible input for cost + */ +export type EstimatedCost = + | string + | SimpleMonetaryAmount + | Omit; + +/** + * Yield type union - flexible input for yield/result quantity + */ +export type HowToYield = + | string + | QuantitativeValue + | Omit; + +/** + * HowTo - Instructions that explain how to achieve a result + * @see https://schema.org/HowTo + */ +export interface HowTo { + "@type": "HowTo"; + name: string; + description?: string; + image?: string | ImageObject | Omit; + estimatedCost?: EstimatedCost; + performTime?: string; + prepTime?: string; + totalTime?: string; + step?: Step | Step[]; + supply?: Supply | Supply[]; + tool?: Tool | Tool[]; + yield?: HowToYield; + video?: VideoObject | Omit; +} + +/** + * Props for the HowToJsonLd component + */ +export type HowToJsonLdProps = Omit & { + scriptId?: string; + scriptKey?: string; +}; diff --git a/src/utils/processors.export.ts b/src/utils/processors.export.ts index 51b0b094..d4ac2af2 100644 --- a/src/utils/processors.export.ts +++ b/src/utils/processors.export.ts @@ -79,6 +79,19 @@ export { processCertification, } from "./processors"; +// HowTo Content +export { + processStep, + processHowToStep, + processHowToSection, + processHowToSupply, + processHowToTool, + processHowToDirection, + processHowToTip, + processEstimatedCost, + processHowToYield, +} from "./processors"; + // Membership & Loyalty export { processMemberProgram, diff --git a/src/utils/processors.ts b/src/utils/processors.ts index ff0511d3..bfe70546 100644 --- a/src/utils/processors.ts +++ b/src/utils/processors.ts @@ -94,6 +94,19 @@ import type { Product, VariesBy, } from "~/types/product.types"; +import type { + HowToSupply, + HowToTool, + HowToDirection, + HowToTip, + HowToStep as HowToStepType, + HowToSection as HowToSectionType, + Step, + Supply, + Tool, + EstimatedCost, + HowToYield, +} from "~/types/howto.types"; // Schema.org type constants const SCHEMA_TYPES = { @@ -135,6 +148,10 @@ const SCHEMA_TYPES = { NUTRITION_INFORMATION: "NutritionInformation", HOW_TO_STEP: "HowToStep", HOW_TO_SECTION: "HowToSection", + HOW_TO_SUPPLY: "HowToSupply", + HOW_TO_TOOL: "HowToTool", + HOW_TO_DIRECTION: "HowToDirection", + HOW_TO_TIP: "HowToTip", PROPERTY_VALUE: "PropertyValue", CREATIVE_WORK: "CreativeWork", DATA_DOWNLOAD: "DataDownload", @@ -2377,3 +2394,264 @@ export function processItemReviewed( return { "@type": inferType(), ...candidate }; } + +// HowTo-specific processors + +/** + * Processes HowTo direction into HowToDirection schema type + * @param direction - HowToDirection object with or without @type + * @returns HowToDirection with @type + */ +export function processHowToDirection( + direction: HowToDirection | Omit, +): HowToDirection { + const processed = processSchemaType( + direction, + SCHEMA_TYPES.HOW_TO_DIRECTION, + ); + + // Process nested media if present + if (direction.beforeMedia && !isString(direction.beforeMedia)) { + processed.beforeMedia = processImage(direction.beforeMedia); + } + if (direction.afterMedia && !isString(direction.afterMedia)) { + processed.afterMedia = processImage(direction.afterMedia); + } + if (direction.duringMedia && !isString(direction.duringMedia)) { + processed.duringMedia = processImage(direction.duringMedia); + } + + return processed; +} + +/** + * Processes HowTo tip into HowToTip schema type + * @param tip - HowToTip object with or without @type + * @returns HowToTip with @type + */ +export function processHowToTip( + tip: HowToTip | Omit, +): HowToTip { + return processSchemaType(tip, SCHEMA_TYPES.HOW_TO_TIP); +} + +/** + * Processes HowTo step item (direction or tip) into appropriate schema type + * @param item - HowToDirection or HowToTip with or without @type + * @returns Processed item with @type + */ +function processHowToStepItem( + item: + | HowToDirection + | HowToTip + | Omit + | Omit, +): HowToDirection | HowToTip { + if (hasType(item)) { + if (item["@type"] === SCHEMA_TYPES.HOW_TO_DIRECTION) { + return processHowToDirection(item as HowToDirection); + } + return processHowToTip(item as HowToTip); + } + + // Infer type based on properties - tips typically only have text + // Directions may have beforeMedia, afterMedia, duringMedia + if ("beforeMedia" in item || "afterMedia" in item || "duringMedia" in item) { + return processHowToDirection(item as Omit); + } + + // Default to direction for items with just text (more common) + return processHowToDirection(item as Omit); +} + +/** + * Processes HowTo step into HowToStep schema type + * @param step - String, HowToStep object with or without @type + * @returns HowToStep with @type + */ +export function processHowToStep( + step: string | HowToStepType | Omit, +): string | HowToStepType { + if (isString(step)) { + return step; + } + + const processed = processSchemaType( + step, + SCHEMA_TYPES.HOW_TO_STEP, + ); + + // Process nested image if present + if (step.image && !isString(step.image)) { + processed.image = processImage(step.image); + } + + // Process nested itemListElement (directions/tips) if present + if (step.itemListElement) { + processed.itemListElement = step.itemListElement.map(processHowToStepItem); + } + + return processed; +} + +/** + * Processes HowTo section into HowToSection schema type + * @param section - HowToSection object with or without @type + * @returns HowToSection with @type + */ +export function processHowToSection( + section: HowToSectionType | Omit, +): HowToSectionType { + const processed = processSchemaType( + section, + SCHEMA_TYPES.HOW_TO_SECTION, + ); + + // Process nested steps + if (section.itemListElement) { + processed.itemListElement = section.itemListElement.map((item) => { + const result = processHowToStep(item); + return typeof result === "string" + ? ({ "@type": "HowToStep", text: result } as HowToStepType) + : result; + }); + } + + return processed; +} + +/** + * Processes HowTo step (can be string, HowToStep, or HowToSection) + * @param step - String, HowToStep, or HowToSection with or without @type + * @returns Processed step with @type + */ +export function processStep( + step: Step, +): string | HowToStepType | HowToSectionType { + if (isString(step)) { + return step; + } + + if (hasType(step)) { + if (step["@type"] === SCHEMA_TYPES.HOW_TO_SECTION) { + return processHowToSection(step as HowToSectionType); + } + const result = processHowToStep(step as HowToStepType); + return typeof result === "string" + ? ({ "@type": "HowToStep", text: result } as HowToStepType) + : result; + } + + // Infer type based on properties + if ("itemListElement" in step && "name" in step) { + // Sections have itemListElement and a name + return processHowToSection(step as Omit); + } + + // Default to step + const result = processHowToStep(step as Omit); + return typeof result === "string" + ? ({ "@type": "HowToStep", text: result } as HowToStepType) + : result; +} + +/** + * Processes HowTo supply into HowToSupply schema type + * @param supply - String or HowToSupply object with or without @type + * @returns HowToSupply with @type + */ +export function processHowToSupply(supply: Supply): HowToSupply { + if (isString(supply)) { + return { + "@type": SCHEMA_TYPES.HOW_TO_SUPPLY, + name: supply, + }; + } + + const processed = processSchemaType( + supply, + SCHEMA_TYPES.HOW_TO_SUPPLY, + ); + + // Process nested image if present + if (supply.image && !isString(supply.image)) { + processed.image = processImage(supply.image); + } + + // Process nested estimatedCost if present + if (supply.estimatedCost && !isString(supply.estimatedCost)) { + processed.estimatedCost = processSimpleMonetaryAmount(supply.estimatedCost); + } + + // Process nested requiredQuantity if present + if (supply.requiredQuantity && typeof supply.requiredQuantity === "object") { + processed.requiredQuantity = processQuantitativeValue( + supply.requiredQuantity, + ); + } + + return processed; +} + +/** + * Processes HowTo tool into HowToTool schema type + * @param tool - String or HowToTool object with or without @type + * @returns HowToTool with @type + */ +export function processHowToTool(tool: Tool): HowToTool { + if (isString(tool)) { + return { + "@type": SCHEMA_TYPES.HOW_TO_TOOL, + name: tool, + }; + } + + const processed = processSchemaType( + tool, + SCHEMA_TYPES.HOW_TO_TOOL, + ); + + // Process nested image if present + if (tool.image && !isString(tool.image)) { + processed.image = processImage(tool.image); + } + + // Process nested requiredQuantity if present + if (tool.requiredQuantity && typeof tool.requiredQuantity === "object") { + processed.requiredQuantity = processQuantitativeValue( + tool.requiredQuantity, + ); + } + + return processed; +} + +/** + * Processes estimated cost into MonetaryAmount schema type + * @param cost - String or MonetaryAmount object with or without @type + * @returns String or MonetaryAmount with @type + */ +export function processEstimatedCost( + cost: EstimatedCost, +): string | SimpleMonetaryAmount { + if (isString(cost)) { + return cost; + } + + return processSimpleMonetaryAmount(cost) as SimpleMonetaryAmount; +} + +/** + * Processes HowTo yield into string or QuantitativeValue schema type + * @param yieldValue - String or QuantitativeValue object with or without @type + * @returns String or QuantitativeValue with @type + */ +export function processHowToYield( + yieldValue: HowToYield, +): string | QuantitativeValue { + if (isString(yieldValue)) { + return yieldValue; + } + + return processQuantitativeValue(yieldValue); +} diff --git a/tests/e2e/howtoJsonLd.e2e.spec.ts b/tests/e2e/howtoJsonLd.e2e.spec.ts new file mode 100644 index 00000000..8c7be6e3 --- /dev/null +++ b/tests/e2e/howtoJsonLd.e2e.spec.ts @@ -0,0 +1,253 @@ +import { test, expect } from "@playwright/test"; + +test.describe("HowToJsonLd", () => { + test("renders basic HowTo structured data", async ({ page }) => { + await page.goto("/howto"); + + // 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 HowTo properties + expect(jsonData["@context"]).toBe("https://schema.org"); + expect(jsonData["@type"]).toBe("HowTo"); + expect(jsonData.name).toBe("How to Change a Flat Tire"); + expect(jsonData.description).toBe( + "Step-by-step guide to safely change a flat tire on the roadside", + ); + expect(jsonData.image).toBe("https://example.com/images/tire-change.jpg"); + expect(jsonData.estimatedCost).toBe("$20"); + expect(jsonData.prepTime).toBe("PT5M"); + expect(jsonData.performTime).toBe("PT25M"); + expect(jsonData.totalTime).toBe("PT30M"); + expect(jsonData.yield).toBe("1 changed tire"); + + // Verify tools array + expect(jsonData.tool).toHaveLength(4); + expect(jsonData.tool[0]).toEqual({ + "@type": "HowToTool", + name: "Spare tire", + }); + expect(jsonData.tool[1]).toEqual({ + "@type": "HowToTool", + name: "Lug wrench", + }); + expect(jsonData.tool[2]).toEqual({ + "@type": "HowToTool", + name: "Jack", + }); + expect(jsonData.tool[3]).toEqual({ + "@type": "HowToTool", + name: "Wheel wedges", + }); + + // Verify supplies array + expect(jsonData.supply).toHaveLength(1); + expect(jsonData.supply[0]).toEqual({ + "@type": "HowToSupply", + name: "Flares", + }); + + // Verify steps array + expect(jsonData.step).toHaveLength(9); + expect(jsonData.step[0]).toBe( + "Turn on your hazard lights and apply parking brake", + ); + expect(jsonData.step[8]).toBe( + "Check the tire pressure and drive to a service station", + ); + }); + + test("renders advanced HowTo with sections and detailed steps", async ({ + page, + }) => { + await page.goto("/howto-advanced"); + + const jsonLdScript = await page + .locator('script[type="application/ld+json"]') + .textContent(); + const jsonData = JSON.parse(jsonLdScript!); + + // Verify HowTo type and name + expect(jsonData["@type"]).toBe("HowTo"); + expect(jsonData.name).toBe("How to Change a Flat Tire"); + + // Verify ImageObject + expect(jsonData.image).toEqual({ + "@type": "ImageObject", + url: "https://example.com/images/tire-change-guide.jpg", + width: 1200, + height: 800, + }); + + // Verify MonetaryAmount estimated cost + expect(jsonData.estimatedCost).toEqual({ + "@type": "MonetaryAmount", + currency: "USD", + value: 20, + }); + + // Verify durations + expect(jsonData.prepTime).toBe("PT5M"); + expect(jsonData.performTime).toBe("PT25M"); + expect(jsonData.totalTime).toBe("PT30M"); + + // Verify tools with images + expect(jsonData.tool).toHaveLength(4); + expect(jsonData.tool[1]).toEqual({ + "@type": "HowToTool", + name: "Lug wrench", + image: "https://example.com/images/lug-wrench.jpg", + }); + expect(jsonData.tool[3]).toEqual({ + "@type": "HowToTool", + name: "Wheel wedges", + image: "https://example.com/images/wheel-wedges.jpg", + }); + + // Verify supply with image + expect(jsonData.supply).toHaveLength(1); + expect(jsonData.supply[0]).toEqual({ + "@type": "HowToSupply", + name: "Flares", + image: "https://example.com/images/flares.jpg", + }); + + // Verify HowToSections + expect(jsonData.step).toHaveLength(3); + + // First section: Preparation + expect(jsonData.step[0]["@type"]).toBe("HowToSection"); + expect(jsonData.step[0].name).toBe("Preparation"); + expect(jsonData.step[0].position).toBe(1); + expect(jsonData.step[0].itemListElement).toHaveLength(2); + + // Check HowToDirection and HowToTip in first step + const firstStep = jsonData.step[0].itemListElement[0]; + expect(firstStep["@type"]).toBe("HowToStep"); + expect(firstStep.itemListElement).toHaveLength(2); + expect(firstStep.itemListElement[0]["@type"]).toBe("HowToDirection"); + expect(firstStep.itemListElement[0].text).toBe( + "Turn on your hazard lights and set the flares.", + ); + expect(firstStep.itemListElement[1]["@type"]).toBe("HowToTip"); + expect(firstStep.itemListElement[1].text).toBe( + "You're going to need space and want to be visible.", + ); + + // Second section: Raise the car + expect(jsonData.step[1]["@type"]).toBe("HowToSection"); + expect(jsonData.step[1].name).toBe("Raise the car"); + expect(jsonData.step[1].position).toBe(2); + expect(jsonData.step[1].itemListElement).toHaveLength(5); + + // Check step with image + expect(jsonData.step[1].itemListElement[0].image).toBe( + "https://example.com/images/position-jack.jpg", + ); + + // Check step with beforeMedia and afterMedia + const raiseStep = jsonData.step[1].itemListElement[1]; + expect(raiseStep.itemListElement[0].beforeMedia).toBe( + "https://example.com/images/car-on-ground.jpg", + ); + expect(raiseStep.itemListElement[0].afterMedia).toBe( + "https://example.com/images/car-raised.jpg", + ); + + // Third section: Finishing up + expect(jsonData.step[2]["@type"]).toBe("HowToSection"); + expect(jsonData.step[2].name).toBe("Finishing up"); + expect(jsonData.step[2].position).toBe(3); + expect(jsonData.step[2].itemListElement).toHaveLength(3); + + // Verify video object + expect(jsonData.video).toEqual({ + "@type": "VideoObject", + name: "How to Change a Tire Video Tutorial", + description: + "Watch our mechanic demonstrate the proper technique for changing a flat tire", + thumbnailUrl: "https://example.com/video/tire-change-thumb.jpg", + contentUrl: "https://example.com/video/tire-change-tutorial.mp4", + uploadDate: "2024-01-15T08:00:00+00:00", + duration: "PT8M30S", + }); + }); + + test("verifies all required properties are present", async ({ page }) => { + await page.goto("/howto"); + + const jsonLdScript = await page + .locator('script[type="application/ld+json"]') + .textContent(); + const jsonData = JSON.parse(jsonLdScript!); + + // Verify required properties according to Schema.org documentation + expect(jsonData).toHaveProperty("@context"); + expect(jsonData).toHaveProperty("@type"); + expect(jsonData).toHaveProperty("name"); + + // Verify the values are not empty + expect(jsonData["@context"]).toBeTruthy(); + expect(jsonData["@type"]).toBeTruthy(); + expect(jsonData.name).toBeTruthy(); + }); + + test("verifies ISO 8601 duration format for time properties", async ({ + page, + }) => { + await page.goto("/howto"); + + const jsonLdScript = await page + .locator('script[type="application/ld+json"]') + .textContent(); + const jsonData = JSON.parse(jsonLdScript!); + + // Verify ISO 8601 duration format + expect(jsonData.prepTime).toMatch(/^PT\d+[HMS]/); + expect(jsonData.performTime).toMatch(/^PT\d+[HMS]/); + expect(jsonData.totalTime).toMatch(/^PT\d+[HMS]/); + + // Verify specific values + expect(jsonData.prepTime).toBe("PT5M"); + expect(jsonData.performTime).toBe("PT25M"); + expect(jsonData.totalTime).toBe("PT30M"); + }); + + test("verifies correct @type values for nested objects", async ({ page }) => { + await page.goto("/howto-advanced"); + + const jsonLdScript = await page + .locator('script[type="application/ld+json"]') + .textContent(); + const jsonData = JSON.parse(jsonLdScript!); + + // Verify tool @type + for (const tool of jsonData.tool) { + expect(tool["@type"]).toBe("HowToTool"); + } + + // Verify supply @type + for (const supply of jsonData.supply) { + expect(supply["@type"]).toBe("HowToSupply"); + } + + // Verify section @type + for (const section of jsonData.step) { + expect(section["@type"]).toBe("HowToSection"); + } + + // Verify video @type + expect(jsonData.video["@type"]).toBe("VideoObject"); + + // Verify image @type + expect(jsonData.image["@type"]).toBe("ImageObject"); + + // Verify estimatedCost @type + expect(jsonData.estimatedCost["@type"]).toBe("MonetaryAmount"); + }); +}); diff --git a/tests/e2e/jsonValidation.e2e.spec.ts b/tests/e2e/jsonValidation.e2e.spec.ts index bef540aa..a55e9da9 100644 --- a/tests/e2e/jsonValidation.e2e.spec.ts +++ b/tests/e2e/jsonValidation.e2e.spec.ts @@ -105,6 +105,51 @@ test.describe("JSON-LD Validation Tests", () => { expect(jsonData!.video).toBeDefined(); }); + test("HowToJsonLd produces valid JSON", async ({ page }) => { + await page.goto("/howto"); + + const jsonLdScript = await page + .locator('script[type="application/ld+json"]') + .textContent(); + + expect(jsonLdScript).toBeTruthy(); + + let jsonData; + expect(() => { + jsonData = JSON.parse(jsonLdScript!); + }).not.toThrow(); + + expect(jsonData).toBeDefined(); + expect(jsonData!["@context"]).toBe("https://schema.org"); + expect(jsonData!["@type"]).toBe("HowTo"); + }); + + test("HowToJsonLd with advanced features produces valid JSON", async ({ + page, + }) => { + await page.goto("/howto-advanced"); + + const jsonLdScript = await page + .locator('script[type="application/ld+json"]') + .textContent(); + + expect(jsonLdScript).toBeTruthy(); + + let jsonData; + expect(() => { + jsonData = JSON.parse(jsonLdScript!); + }).not.toThrow(); + + expect(jsonData).toBeDefined(); + expect(jsonData!["@context"]).toBe("https://schema.org"); + expect(jsonData!["@type"]).toBe("HowTo"); + // Check nested objects are valid + expect(jsonData!.step).toBeDefined(); + expect(jsonData!.tool).toBeDefined(); + expect(jsonData!.supply).toBeDefined(); + expect(jsonData!.video).toBeDefined(); + }); + test("OrganizationJsonLd produces valid JSON", async ({ page }) => { await page.goto("/organization");