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
+
+ -
+ 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.
+
+
+ -
+ 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.
+
+
+
+
+ Section 2: Raise the Car
+
+ -
+ Position the jack: Place it underneath the car,
+ next to the flat tire.
+
+ -
+ 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.
+
+
+ -
+ Remove hubcap: Take off the hubcap and loosen the
+ lug nuts.
+
+ -
+ Swap the tire: Remove the flat and mount the spare
+ on the lug bolts.
+
+ -
+ Hand-tighten: Tighten the lug nuts by hand first.
+
+ 💡 Tip: Don't use the wrench yet - wait until the car is lowered.
+
+
+
+
+ Section 3: Finishing Up
+
+ -
+ Lower and tighten: Lower the jack and use the
+ wrench to fully tighten lug nuts.
+
+ -
+ Replace hubcap: Put the hubcap back on if your
+ spare uses one.
+
+ -
+ Store equipment: Put away the jack, wrench, and
+ flat tire.
+
+
+
+
+
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
+
+ -
+ Secure the vehicle: Turn on your hazard lights and
+ apply the parking brake.
+
+ -
+ Prevent rolling: Apply wheel wedges behind the
+ tires to prevent the vehicle from moving.
+
+ -
+ Prepare the wheel: Remove the hubcap and loosen the
+ lug nuts (turn counterclockwise) while the tire is still on the
+ ground.
+
+ -
+ Position the jack: Place the jack under the vehicle
+ frame near the flat tire.
+
+ -
+ Raise the vehicle: Use the jack to raise the
+ vehicle until the flat tire is about 6 inches off the ground.
+
+ -
+ Remove the flat: Remove the lug nuts completely and
+ pull off the flat tire.
+
+ -
+ Mount the spare: Place the spare tire on the lug
+ bolts and hand-tighten the lug nuts.
+
+ -
+ Lower and tighten: Lower the vehicle and fully
+ tighten the lug nuts in a star pattern.
+
+ -
+ Final check: Check the tire pressure and drive to a
+ service station to have the spare properly inspected.
+
+
+
+
+
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");