Repository: treely/strapi-slices
Pull request #166: feat: option to pass schema markups to seotags
Author: login: rschiffer
Labels: name: released
Created: 2025-05-26T20:42:26Z | Merged: 2025-05-28T08:32:42Z

Impediment: https://trello.com/c/xtZNaP9U/127-add-schema-markups-to-our-website


Diff:
diff --git a/.gitignore b/.gitignore
index 6e81e1f0..d536f333 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,4 @@ node_modules
 dist
 coverage
 .vscode
+.prettierrc
diff --git a/package-lock.json b/package-lock.json
index e5c351c0..774e268f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,6 +20,7 @@
         "formik": "^2.4.5",
         "framer-motion": "^10.16.5",
         "mapbox-gl": "^2.15.0",
+        "schema-dts": "^1.1.5",
         "swr": "^2.3.2",
         "yocto-queue": "^1.2.0"
       },
@@ -23715,6 +23716,11 @@
         "loose-envify": "^1.1.0"
       }
     },
+    "node_modules/schema-dts": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/schema-dts/-/schema-dts-1.1.5.tgz",
+      "integrity": "sha512-RJr9EaCmsLzBX2NDiO5Z3ux2BVosNZN5jo0gWgsyKvxKIUL5R3swNvoorulAeL9kLB0iTSX7V6aokhla2m7xbg=="
+    },
     "node_modules/schema-utils": {
       "version": "4.3.0",
       "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz",
diff --git a/package.json b/package.json
index c1d5412e..b9a63dd4 100644
--- a/package.json
+++ b/package.json
@@ -153,6 +153,7 @@
     "formik": "^2.4.5",
     "framer-motion": "^10.16.5",
     "mapbox-gl": "^2.15.0",
+    "schema-dts": "^1.1.5",
     "swr": "^2.3.2",
     "yocto-queue": "^1.2.0"
   },
diff --git a/src/components/SEOTags/SEOTags.tsx b/src/components/SEOTags/SEOTags.tsx
index eccb55b2..04ca59d5 100644
--- a/src/components/SEOTags/SEOTags.tsx
+++ b/src/components/SEOTags/SEOTags.tsx
@@ -4,6 +4,96 @@ import {
   DEFAULT_SHARE_ALT,
   DEFAULT_SHARE_IMAGE,
 } from '../../constants/metadata';
+import {
+  Article,
+  BlogPosting,
+  Brand,
+  BreadcrumbList,
+  Event,
+  FAQPage,
+  HowTo,
+  LocalBusiness,
+  Offer,
+  Organization,
+  Person,
+  Product,
+  Service,
+  WebPage,
+  WithContext,
+} from 'schema-dts';
+
+type SupportedSchemaType =
+  | Article
+  | BlogPosting
+  | Brand
+  | BreadcrumbList
+  | Event
+  | FAQPage
+  | HowTo
+  | LocalBusiness
+  | Offer
+  | Organization
+  | Person
+  | Product
+  | Service
+  | WebPage;
+
+// Helper function to convert SchemaValue to string
+const getTextValue = (value: unknown): string => {
+  if (typeof value === 'string') return value;
+  if (typeof value === 'object' && value !== null && 'text' in value) {
+    return (value as { text: string }).text;
+  }
+  return '';
+};
+
+// Helper function to safely access properties from a schema
+const getSchemaProperty = (
+  schema: SupportedSchemaType,
+  property: string
+): string => {
+  return (
+    getTextValue((schema as unknown as Record<string, unknown>)[property]) || ''
+  );
+};
+
+// Helper function to get a unique identifier from a schema
+const getSchemaIdentifier = (schema: SupportedSchemaType): string => {
+  const type = (schema as { '@type': string })['@type'];
+
+  switch (type) {
+    case 'Organization':
+      return getSchemaProperty(schema, 'name') || 'default';
+    case 'Article':
+    case 'BlogPosting':
+      return getSchemaProperty(schema, 'headline') || 'untitled-article';
+    case 'Product':
+      return getSchemaProperty(schema, 'name') || 'untitled-product';
+    case 'Person':
+      return getSchemaProperty(schema, 'name') || 'unnamed-person';
+    case 'Event':
+      return getSchemaProperty(schema, 'name') || 'untitled-event';
+    case 'LocalBusiness':
+      return getSchemaProperty(schema, 'name') || 'unnamed-business';
+    case 'Service':
+      return getSchemaProperty(schema, 'name') || 'unnamed-service';
+    case 'Brand':
+      return getSchemaProperty(schema, 'name') || 'unnamed-brand';
+    case 'FAQPage':
+      return 'faq-page';
+    case 'HowTo':
+      return getSchemaProperty(schema, 'name') || 'untitled-howto';
+    case 'BreadcrumbList':
+      return 'breadcrumbs';
+    case 'Offer':
+      const offer = schema as Offer;
+      return `offer-${offer.price ?? 'unknown-price'}`;
+    case 'WebPage':
+      return getSchemaProperty(schema, 'name') || 'untitled-page';
+    default:
+      return 'unknown-schema';
+  }
+};
 
 interface SEOTagsProps {
   title: string;
@@ -15,8 +105,57 @@ interface SEOTagsProps {
   metaTitleSuffix?: string;
   favicon?: string;
   domain?: string;
+  /**
+   * Structured data for SEO purposes, following the schema.org standard.
+   * This can be a single schema object or an array of schema objects.
+   * Each object must include an `@context` property set to "https://schema.org"
+   * and an `@type` property indicating the type of schema (e.g., Article, Product).
+   * The schema falls back to the default schema if it is invalid.
+   */
+  schemaMarkup?:
+    | WithContext<SupportedSchemaType>
+    | WithContext<SupportedSchemaType>[];
 }
 
+const validateSchema = (
+  schema: WithContext<SupportedSchemaType> | WithContext<SupportedSchemaType>[]
+): boolean => {
+  if (Array.isArray(schema)) {
+    return schema.every(
+      (item) => item['@context'] === 'https://schema.org' && '@type' in item
+    );
+  }
+  return schema['@context'] === 'https://schema.org' && '@type' in schema;
+};
+
+const DEFAULT_SCHEMA: WithContext<Organization> = {
+  '@context': 'https://schema.org',
+  '@type': 'Organization',
+  name: 'Tree.ly',
+  url: 'https://tree.ly',
+  logo: 'https://cdn.tree.ly/logo.png',
+  address: {
+    '@type': 'PostalAddress',
+    streetAddress: 'Littengasse 2b/c',
+    addressLocality: 'Dornbirn',
+    postalCode: '6850',
+    addressRegion: 'Vorarlberg',
+    addressCountry: 'AT',
+  },
+  contactPoint: {
+    '@type': 'ContactPoint',
+    telephone: '+43-5572-432015',
+    contactType: 'Customer Service',
+    areaServed: 'AT',
+    availableLanguage: ['English', 'German'],
+  },
+  sameAs: [
+    'https://www.linkedin.com/company/tree-ly',
+    'https://www.facebook.com/treely',
+    'https://www.instagram.com/treely',
+  ],
+};
+
 export const SEOTags: React.FC<SEOTagsProps> = ({
   title,
   description,
@@ -24,10 +163,35 @@ export const SEOTags: React.FC<SEOTagsProps> = ({
   metaTitleSuffix = 'Tree.ly',
   favicon = 'https://cdn.tree.ly/favicon.ico',
   domain = 'tree.ly',
+  schemaMarkup,
 }: SEOTagsProps) => {
   const shareImageUrl = shareImage?.url ?? DEFAULT_SHARE_IMAGE;
   const shareImageAlt = shareImage?.alt ?? DEFAULT_SHARE_ALT;
 
+  let schemas = schemaMarkup || DEFAULT_SCHEMA;
+  let isValidSchema = validateSchema(schemas);
+
+  if (schemaMarkup && !isValidSchema) {
+    console.warn(
+      'Invalid schema markup provided to SEOTags component. Falling back to default schema.',
+      schemaMarkup
+    );
+    schemas = DEFAULT_SCHEMA;
+    isValidSchema = true;
+  }
+
+  const schemaArray = Array.isArray(schemas) ? schemas : [schemas];
+
+  const getSchemaKey = (
+    schema: WithContext<SupportedSchemaType>,
+    index: number
+  ): string => {
+    const type = (schema as { '@type': string })['@type'];
+    const identifier = getSchemaIdentifier(schema as SupportedSchemaType);
+    // Add index to ensure uniqueness, especially for fallback identifiers
+    return `${type}-${identifier}-${index}`;
+  };
+
   return (
     <Head>
       <title>{`${title} - ${metaTitleSuffix}`}</title>
@@ -48,6 +212,17 @@ export const SEOTags: React.FC<SEOTagsProps> = ({
       <meta name="twitter:description" content={description} />
       <meta name="twitter:image" content={shareImageUrl} />
       <meta name="twitter:image:alt" content={shareImageAlt} />
+
+      {isValidSchema &&
+        schemaArray.map((schema, index) => (
+          <script
+            key={getSchemaKey(schema, index)}
+            type="application/ld+json"
+            dangerouslySetInnerHTML={{
+              __html: JSON.stringify(schema),
+            }}
+          />
+        ))}
     </Head>
   );
 };


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR introduces a new feature that allows passing custom schema markups to the SEOTags component. The key changes include:
- Adding a new optional prop "schemaMarkup" to SEOTags.
- Implementing a new "validateSchema" function to verify the schema format.
- Adding a dependency on "schema-dts" in package.json.

### Reviewed Changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

| File                                        | Description                                        |
| ------------------------------------------- | -------------------------------------------------- |
| src/components/SEOTags/SEOTags.tsx            | Added support for custom schema markups and validation logic. |
| package.json                                | Added schema-dts dependency.                        |


<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**src/components/SEOTags/SEOTags.tsx:49**
* The schema validation is incorrectly checking for the property 'type' instead of '@type'. Update the check to 'item["@type"]' to ensure the schema object is correctly validated.
```
      item['@context'] === 'https://schema.org' && 'type' in item
```
</details>


state: COMMENTED
author_association: NONE
submitted_at: 2025-05-26T20:42:56Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR adds support for passing custom JSON-LD schema markups into the SEOTags component by introducing a new `schemaMarkup` prop, defaulting to an `Organization` schema when none is provided, and rendering the schemas as `<script type="application/ld+json">` tags.

- Added `schemaMarkup` prop and `SupportedSchemaType` definitions using `schema-dts`
- Implemented `validateSchema` and default organization schema
- Render JSON-LD schema scripts and added `schema-dts` dependency in `package.json`

### Reviewed Changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

| File                                        | Description                                            |
| ------------------------------------------- | ------------------------------------------------------ |
| src/components/SEOTags/SEOTags.tsx          | Adds `schemaMarkup` prop, schema validation, default schema, and JSON-LD injection |
| package.json                                | Adds `schema-dts` dependency for schema typings        |


<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**src/components/SEOTags/SEOTags.tsx:56**
* Introduce unit tests to cover `validateSchema` behavior for both valid and invalid inputs to ensure schema validation correctness.
```
const validateSchema = (
```
</details>


state: COMMENTED
author_association: NONE
submitted_at: 2025-05-27T08:45:21Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

Adds optional schema.org structured data support to the SEOTags component.
- Introduces a new `schemaMarkup` prop for passing single or multiple schema objects.
- Implements validation and fallback to a default `Organization` schema.
- Renders JSON-LD `<script>` tags and adds the `schema-dts` dependency.

### Reviewed Changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

| File                                     | Description                                                                                      |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------ |
| src/components/SEOTags/SEOTags.tsx       | Imported schema types, added `schemaMarkup` prop, validation logic, default schema, and JSON-LD rendering |
| package.json                             | Added `schema-dts` dependency                                                                    |


<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**src/components/SEOTags/SEOTags.tsx:119**
* Add unit tests for `validateSchema` to cover both single-object and array inputs, valid and invalid cases.
```
const validateSchema = (
```
</details>


state: COMMENTED
author_association: NONE
submitted_at: 2025-05-27T11:12:20Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

Adds the ability to pass custom schema markups into the SEOTags component, with validation and fallback to a default Organization schema.

- Imports `schema-dts` types and defines a `SupportedSchemaType` union
- Introduces helper functions (`getTextValue`, `getSchemaIdentifier`, `validateSchema`) and a `DEFAULT_SCHEMA`
- Extends `SEOTags` to accept a `schemaMarkup` prop, validate it, and render one or more JSON-LD `<script>` tags

### Reviewed Changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

| File                                   | Description                                                                          |
| -------------------------------------- | ------------------------------------------------------------------------------------ |
| src/components/SEOTags/SEOTags.tsx     | Added schema-dts types, validation helpers, default schema, new prop handling, and JSON-LD rendering |
| package.json                           | Added `"schema-dts"` dependency                                                      |


<details>
<summary>Comments suppressed due to low confidence (2)</summary>

**src/components/SEOTags/SEOTags.tsx:117**
* New schema validation logic in validateSchema should be accompanied by unit tests to ensure correct behavior for various valid and invalid schema inputs.
```
const validateSchema = (
```
**src/components/SEOTags/SEOTags.tsx:172**
* [nitpick] The warning message for invalid schema markup could be more specific by including details on what validation failed to aid debugging.
```
console.warn(
```
</details>


state: COMMENTED
author_association: NONE
submitted_at: 2025-05-27T11:16:41Z
user: login: lukasbals
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-05-27T11:22:07Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR adds support for passing structured schema markup data to the SEOTags component to enhance SEO through schema.org. The changes include new helper functions for schema value extraction and identifier generation, schema validation logic with a fallback to a default Organization schema, and an update to package.json to include the "schema-dts" dependency.

### Reviewed Changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

| File                                                        | Description                                                          |
| ----------------------------------------------------------- | -------------------------------------------------------------------- |
| src/components/SEOTags/SEOTags.tsx                           | Added support for custom schema markups, helper functions, and fallback logic for invalid schemas. |
| package.json                                                | Added the "schema-dts" dependency required for schema markup processing. |




state: COMMENTED
author_association: NONE
submitted_at: 2025-05-27T11:35:19Z
user: login: ClaraTschamon
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-28T07:29:48Z
user: login: ClaraTschamon
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-28T08:32:25Z

Review comments:
diff_hunk: @@ -15,19 +39,65 @@ interface SEOTagsProps {
   metaTitleSuffix?: string;
   favicon?: string;
   domain?: string;
+  schemaMarkup?: WithContext<SupportedSchemaType> | WithContext<SupportedSchemaType>[];
 }
 
+const validateSchema = (schema: WithContext<SupportedSchemaType> | WithContext<SupportedSchemaType>[]): boolean => {
+  if (Array.isArray(schema)) {
+    return schema.every(item =>
+      item['@context'] === 'https://schema.org' &&
+      'type' in item
+    );
+  }
+  return schema['@context'] === 'https://schema.org' && 'type' in schema;
path: src/components/SEOTags/SEOTags.tsx
user: login: Copilot
body: Similarly, the non-array branch in validateSchema also checks for 'type' instead of '@type'. Update the condition to check for '@type' to correctly validate the schema markup.
```suggestion
      '@type' in item
    );
  }
  return schema['@context'] === 'https://schema.org' && '@type' in schema;
```
created_at: 2025-05-26T20:42:55Z
author_association: NONE
diff_hunk: @@ -15,19 +48,66 @@ interface SEOTagsProps {
   metaTitleSuffix?: string;
   favicon?: string;
   domain?: string;
path: src/components/SEOTags/SEOTags.tsx
user: login: Copilot
body: Add a JSDoc comment or interface doc to explain the purpose and expected shape of `schemaMarkup` for better developer guidance.
```suggestion
  domain?: string;
  /**
   * Structured data for SEO purposes, following the schema.org standard.
   * This can be a single schema object or an array of schema objects.
   * Each object must include an `@context` property set to "https://schema.org"
   * and an `@type` property indicating the type of schema (e.g., Article, Product).
   */
```
created_at: 2025-05-27T08:45:20Z
author_association: NONE
diff_hunk: @@ -15,19 +48,66 @@ interface SEOTagsProps {
   metaTitleSuffix?: string;
   favicon?: string;
   domain?: string;
+  schemaMarkup?:
+    | WithContext<SupportedSchemaType>
+    | WithContext<SupportedSchemaType>[];
 }
 
+const validateSchema = (
+  schema: WithContext<SupportedSchemaType> | WithContext<SupportedSchemaType>[]
+): boolean => {
+  if (Array.isArray(schema)) {
+    return schema.every(
+      (item) => item['@context'] === 'https://schema.org' && '@type' in item
+    );
+  }
+  return schema['@context'] === 'https://schema.org' && '@type' in schema;
+};
+
 export const SEOTags: React.FC<SEOTagsProps> = ({
   title,
   description,
   shareImage,
   metaTitleSuffix = 'Tree.ly',
   favicon = 'https://cdn.tree.ly/favicon.ico',
   domain = 'tree.ly',
+  schemaMarkup,
 }: SEOTagsProps) => {
   const shareImageUrl = shareImage?.url ?? DEFAULT_SHARE_IMAGE;
   const shareImageAlt = shareImage?.alt ?? DEFAULT_SHARE_ALT;
 
+  const defaultSchema: WithContext<Organization> = {
+    '@context': 'https://schema.org',
+    '@type': 'Organization',
+    name: 'Tree.ly',
+    url: `https://${domain}`,
+    logo: 'https://cdn.tree.ly/logo.png',
+    address: {
+      '@type': 'PostalAddress',
+      streetAddress: 'Littengasse 2b/c',
+      addressLocality: 'Dornbirn',
+      postalCode: '6850',
+      addressRegion: 'Vorarlberg',
+      addressCountry: 'AT',
+    },
+    contactPoint: {
+      '@type': 'ContactPoint',
+      telephone: '+43-5572-432015',
+      contactType: 'Customer Service',
+      areaServed: 'AT',
+      availableLanguage: ['English', 'German'],
+    },
+    sameAs: [
+      'https://www.linkedin.com/company/tree-ly',
+      'https://www.facebook.com/treely',
+      'https://www.instagram.com/treely',
+    ],
+  };
path: src/components/SEOTags/SEOTags.tsx
user: login: Copilot
body: [nitpick] Consider moving `defaultSchema` out of the component (e.g. top-level constant) or memoizing it to avoid re-creating the object on every render.
```suggestion
  const schemas = schemaMarkup || defaultSchema;
```
created_at: 2025-05-27T08:45:20Z
author_association: NONE
diff_hunk: @@ -15,19 +48,66 @@ interface SEOTagsProps {
   metaTitleSuffix?: string;
   favicon?: string;
   domain?: string;
+  schemaMarkup?:
+    | WithContext<SupportedSchemaType>
+    | WithContext<SupportedSchemaType>[];
 }
 
+const validateSchema = (
+  schema: WithContext<SupportedSchemaType> | WithContext<SupportedSchemaType>[]
+): boolean => {
+  if (Array.isArray(schema)) {
+    return schema.every(
+      (item) => item['@context'] === 'https://schema.org' && '@type' in item
+    );
+  }
+  return schema['@context'] === 'https://schema.org' && '@type' in schema;
+};
+
 export const SEOTags: React.FC<SEOTagsProps> = ({
   title,
   description,
   shareImage,
   metaTitleSuffix = 'Tree.ly',
   favicon = 'https://cdn.tree.ly/favicon.ico',
   domain = 'tree.ly',
+  schemaMarkup,
 }: SEOTagsProps) => {
   const shareImageUrl = shareImage?.url ?? DEFAULT_SHARE_IMAGE;
   const shareImageAlt = shareImage?.alt ?? DEFAULT_SHARE_ALT;
 
+  const defaultSchema: WithContext<Organization> = {
+    '@context': 'https://schema.org',
+    '@type': 'Organization',
+    name: 'Tree.ly',
+    url: `https://${domain}`,
+    logo: 'https://cdn.tree.ly/logo.png',
+    address: {
+      '@type': 'PostalAddress',
+      streetAddress: 'Littengasse 2b/c',
+      addressLocality: 'Dornbirn',
+      postalCode: '6850',
+      addressRegion: 'Vorarlberg',
+      addressCountry: 'AT',
+    },
+    contactPoint: {
+      '@type': 'ContactPoint',
+      telephone: '+43-5572-432015',
+      contactType: 'Customer Service',
+      areaServed: 'AT',
+      availableLanguage: ['English', 'German'],
+    },
+    sameAs: [
+      'https://www.linkedin.com/company/tree-ly',
+      'https://www.facebook.com/treely',
+      'https://www.instagram.com/treely',
+    ],
+  };
+
+  const schemas = schemaMarkup || defaultSchema;
+  const isValidSchema = validateSchema(schemas);
path: src/components/SEOTags/SEOTags.tsx
user: login: Copilot
body: If `schemaMarkup` is provided but fails validation, the default schema is skipped silently; consider falling back to the default or logging a warning to avoid missing JSON-LD output.
```suggestion
  let schemas = defaultSchema;
  if (schemaMarkup) {
    if (validateSchema(schemaMarkup)) {
      schemas = schemaMarkup;
    } else {
      console.warn('Provided schemaMarkup is invalid. Falling back to default schema.');
    }
  }
```
created_at: 2025-05-27T08:45:20Z
author_association: NONE
diff_hunk: @@ -48,6 +128,17 @@ export const SEOTags: React.FC<SEOTagsProps> = ({
       <meta name="twitter:description" content={description} />
       <meta name="twitter:image" content={shareImageUrl} />
       <meta name="twitter:image:alt" content={shareImageAlt} />
+
+      {isValidSchema &&
+        schemaArray.map((schema, index) => (
+          <script
+            key={`schema-${index}`}
path: src/components/SEOTags/SEOTags.tsx
user: login: Copilot
body: Using the array index as `key` can cause issues if the array changes; prefer a stable unique identifier (e.g. a schema `@type` or a custom ID) when possible.
```suggestion
        schemaArray.map((schema) => (
          <script
            key={`schema-${schema['@type'] || JSON.stringify(schema).hashCode()}`}
```
created_at: 2025-05-27T08:45:21Z
author_association: NONE
diff_hunk: @@ -4,6 +4,96 @@ import {
   DEFAULT_SHARE_ALT,
   DEFAULT_SHARE_IMAGE,
 } from '../../constants/metadata';
+import {
+  Article,
+  BlogPosting,
+  Brand,
+  BreadcrumbList,
+  Event,
+  FAQPage,
+  HowTo,
+  LocalBusiness,
+  Offer,
+  Organization,
+  Person,
+  Product,
+  Service,
+  WebPage,
+  WithContext,
+} from 'schema-dts';
+
+type SupportedSchemaType =
+  | Article
+  | BlogPosting
+  | Brand
+  | BreadcrumbList
+  | Event
+  | FAQPage
+  | HowTo
+  | LocalBusiness
+  | Offer
+  | Organization
+  | Person
+  | Product
+  | Service
+  | WebPage;
+
+// Helper type to get the type name from a schema
+type SchemaType<T> = T extends { '@type': infer U } ? U : never;
path: src/components/SEOTags/SEOTags.tsx
user: login: Copilot
body: The helper type `SchemaType` is declared but never used. Consider removing it or using it to improve type safety.
created_at: 2025-05-27T11:12:19Z
author_association: NONE
diff_hunk: @@ -4,6 +4,96 @@ import {
   DEFAULT_SHARE_ALT,
   DEFAULT_SHARE_IMAGE,
 } from '../../constants/metadata';
+import {
+  Article,
+  BlogPosting,
+  Brand,
+  BreadcrumbList,
+  Event,
+  FAQPage,
+  HowTo,
+  LocalBusiness,
+  Offer,
+  Organization,
+  Person,
+  Product,
+  Service,
+  WebPage,
+  WithContext,
+} from 'schema-dts';
+
+type SupportedSchemaType =
+  | Article
+  | BlogPosting
+  | Brand
+  | BreadcrumbList
+  | Event
+  | FAQPage
+  | HowTo
+  | LocalBusiness
+  | Offer
+  | Organization
+  | Person
+  | Product
+  | Service
+  | WebPage;
+
+// Helper type to get the type name from a schema
+type SchemaType<T> = T extends { '@type': infer U } ? U : never;
+
+// Helper function to convert SchemaValue to string
+const getTextValue = (value: unknown): string => {
+  if (typeof value === 'string') return value;
+  if (typeof value === 'object' && value !== null && 'text' in value) {
+    return (value as { text: string }).text;
+  }
+  return '';
+};
+
+// Helper function to get a unique identifier from a schema
+const getSchemaIdentifier = (schema: SupportedSchemaType): string => {
+  const type = (schema as { '@type': string })['@type'];
+
+  switch (type) {
+    case 'Organization':
+      return getTextValue((schema as { name?: string }).name) || 'default';
+    case 'Article':
+    case 'BlogPosting':
+      return (
+        getTextValue((schema as Article | BlogPosting).headline) ||
+        'untitled-article'
+      );
+    case 'Product':
+      return getTextValue((schema as Product).name) || 'untitled-product';
+    case 'Person':
+      return (
+        getTextValue((schema as { name?: string }).name) || 'unnamed-person'
+      );
+    case 'Event':
+      return getTextValue((schema as Event).name) || 'untitled-event';
+    case 'LocalBusiness':
+      return (
+        getTextValue((schema as { name?: string }).name) || 'unnamed-business'
+      );
+    case 'Service':
+      return getTextValue((schema as Service).name) || 'unnamed-service';
+    case 'Brand':
+      return getTextValue((schema as Brand).name) || 'unnamed-brand';
+    case 'FAQPage':
+      return 'faq-page';
+    case 'HowTo':
+      return getTextValue((schema as HowTo).name) || 'untitled-howto';
+    case 'BreadcrumbList':
+      return 'breadcrumbs';
+    case 'Offer':
+      const offer = schema as Offer;
+      return `offer-${offer.price || 'unknown-price'}`;
path: src/components/SEOTags/SEOTags.tsx
user: login: Copilot
body: Using `||` will treat price `0` as falsy and fall back to `'unknown-price'`. Use nullish coalescing (`offer.price ?? 'unknown-price'`) to correctly handle zero values.
```suggestion
      return `offer-${offer.price ?? 'unknown-price'}`;
```
created_at: 2025-05-27T11:12:20Z
author_association: NONE
diff_hunk: @@ -15,19 +105,88 @@ interface SEOTagsProps {
   metaTitleSuffix?: string;
   favicon?: string;
   domain?: string;
+  /**
path: src/components/SEOTags/SEOTags.tsx
user: login: Copilot
body: [nitpick] Enhance the `schemaMarkup` prop JSDoc to mention the fallback to the default schema when validation fails.
created_at: 2025-05-27T11:12:20Z
author_association: NONE
diff_hunk: @@ -15,19 +102,89 @@ interface SEOTagsProps {
   metaTitleSuffix?: string;
   favicon?: string;
   domain?: string;
+  /**
+   * Structured data for SEO purposes, following the schema.org standard.
+   * This can be a single schema object or an array of schema objects.
+   * Each object must include an `@context` property set to "https://schema.org"
+   * and an `@type` property indicating the type of schema (e.g., Article, Product).
+   * The schema falls back to the default schema if it is invalid.
+   */
+  schemaMarkup?:
+    | WithContext<SupportedSchemaType>
+    | WithContext<SupportedSchemaType>[];
 }
 
+const validateSchema = (
+  schema: WithContext<SupportedSchemaType> | WithContext<SupportedSchemaType>[]
+): boolean => {
+  if (Array.isArray(schema)) {
+    return schema.every(
+      (item) => item['@context'] === 'https://schema.org' && '@type' in item
+    );
+  }
+  return schema['@context'] === 'https://schema.org' && '@type' in schema;
+};
+
+const DEFAULT_SCHEMA: WithContext<Organization> = {
+  '@context': 'https://schema.org',
+  '@type': 'Organization',
+  name: 'Tree.ly',
+  url: 'https://tree.ly',
+  logo: 'https://cdn.tree.ly/logo.png',
+  address: {
+    '@type': 'PostalAddress',
+    streetAddress: 'Littengasse 2b/c',
+    addressLocality: 'Dornbirn',
+    postalCode: '6850',
+    addressRegion: 'Vorarlberg',
+    addressCountry: 'AT',
+  },
+  contactPoint: {
+    '@type': 'ContactPoint',
+    telephone: '+43-5572-432015',
+    contactType: 'Customer Service',
+    areaServed: 'AT',
+    availableLanguage: ['English', 'German'],
+  },
+  sameAs: [
+    'https://www.linkedin.com/company/tree-ly',
+    'https://www.facebook.com/treely',
+    'https://www.instagram.com/treely',
+  ],
+};
+
 export const SEOTags: React.FC<SEOTagsProps> = ({
   title,
   description,
   shareImage,
   metaTitleSuffix = 'Tree.ly',
   favicon = 'https://cdn.tree.ly/favicon.ico',
   domain = 'tree.ly',
+  schemaMarkup,
 }: SEOTagsProps) => {
   const shareImageUrl = shareImage?.url ?? DEFAULT_SHARE_IMAGE;
   const shareImageAlt = shareImage?.alt ?? DEFAULT_SHARE_ALT;
 
+  let schemas = schemaMarkup || DEFAULT_SCHEMA;
+  let isValidSchema = validateSchema(schemas);
+
+  if (schemaMarkup && !isValidSchema) {
+    console.warn(
+      'Invalid schema markup provided to SEOTags component. Falling back to default schema.',
+      schemaMarkup
+    );
+    schemas = DEFAULT_SCHEMA;
+    isValidSchema = true;
+  }
+
+  const schemaArray = Array.isArray(schemas) ? schemas : [schemas];
+
+  const getSchemaKey = (schema: WithContext<SupportedSchemaType>): string => {
+    const type = (schema as { '@type': string })['@type'];
+    const identifier = getSchemaIdentifier(schema as SupportedSchemaType);
+    return `${type}-${identifier}`;
path: src/components/SEOTags/SEOTags.tsx
user: login: Copilot
body: Using type–identifier combinations as React keys may produce duplicates when fallback identifiers resolve to the same string; consider appending an index or other unique marker to ensure key uniqueness.
```suggestion
  const getSchemaKey = (schema: WithContext<SupportedSchemaType>, index: number): string => {
    const type = (schema as { '@type': string })['@type'];
    const identifier = getSchemaIdentifier(schema as SupportedSchemaType);
    return `${type}-${identifier}-${index}`;
```
created_at: 2025-05-27T11:16:40Z
author_association: NONE
diff_hunk: @@ -4,6 +4,93 @@ import {
   DEFAULT_SHARE_ALT,
   DEFAULT_SHARE_IMAGE,
 } from '../../constants/metadata';
+import {
+  Article,
+  BlogPosting,
+  Brand,
+  BreadcrumbList,
+  Event,
+  FAQPage,
+  HowTo,
+  LocalBusiness,
+  Offer,
+  Organization,
+  Person,
+  Product,
+  Service,
+  WebPage,
+  WithContext,
+} from 'schema-dts';
+
+type SupportedSchemaType =
+  | Article
+  | BlogPosting
+  | Brand
+  | BreadcrumbList
+  | Event
+  | FAQPage
+  | HowTo
+  | LocalBusiness
+  | Offer
+  | Organization
+  | Person
+  | Product
+  | Service
+  | WebPage;
+
+// Helper function to convert SchemaValue to string
+const getTextValue = (value: unknown): string => {
+  if (typeof value === 'string') return value;
+  if (typeof value === 'object' && value !== null && 'text' in value) {
+    return (value as { text: string }).text;
+  }
+  return '';
+};
+
+// Helper function to get a unique identifier from a schema
path: src/components/SEOTags/SEOTags.tsx
user: login: Copilot
body: [nitpick] Missing documentation for getSchemaIdentifier: consider adding a description of its behavior and examples for each schema type to improve maintainability.
```suggestion
/**
 * Generates a unique identifier for a given schema object based on its type and properties.
 *
 * @param {SupportedSchemaType} schema - The schema object to generate an identifier for.
 * @returns {string} A unique identifier for the schema.
 *
 * The function determines the schema type using the `@type` property and extracts
 * relevant fields to create a meaningful identifier. If no suitable field is found,
 * a default identifier is returned.
 *
 * Examples:
 * - For an `Organization` schema: { "@type": "Organization", "name": "Acme Corp" }
 *   -> "Acme Corp"
 * - For an `Article` schema: { "@type": "Article", "headline": "Breaking News" }
 *   -> "Breaking News"
 * - For a `Product` schema: { "@type": "Product", "name": "Widget" }
 *   -> "Widget"
 * - For an `Offer` schema: { "@type": "Offer", "price": "19.99" }
 *   -> "offer-19.99"
 * - For an unknown schema type: { "@type": "UnknownType" }
 *   -> "unknown-schema"
 */
```
created_at: 2025-05-27T11:16:41Z
author_association: NONE
diff_hunk: @@ -4,6 +4,93 @@ import {
   DEFAULT_SHARE_ALT,
   DEFAULT_SHARE_IMAGE,
 } from '../../constants/metadata';
+import {
+  Article,
+  BlogPosting,
+  Brand,
+  BreadcrumbList,
+  Event,
+  FAQPage,
+  HowTo,
+  LocalBusiness,
+  Offer,
+  Organization,
+  Person,
+  Product,
+  Service,
+  WebPage,
+  WithContext,
+} from 'schema-dts';
+
+type SupportedSchemaType =
+  | Article
+  | BlogPosting
+  | Brand
+  | BreadcrumbList
+  | Event
+  | FAQPage
+  | HowTo
+  | LocalBusiness
+  | Offer
+  | Organization
+  | Person
+  | Product
+  | Service
+  | WebPage;
+
+// Helper function to convert SchemaValue to string
+const getTextValue = (value: unknown): string => {
+  if (typeof value === 'string') return value;
+  if (typeof value === 'object' && value !== null && 'text' in value) {
+    return (value as { text: string }).text;
+  }
+  return '';
+};
+
+// Helper function to get a unique identifier from a schema
+const getSchemaIdentifier = (schema: SupportedSchemaType): string => {
+  const type = (schema as { '@type': string })['@type'];
+
+  switch (type) {
+    case 'Organization':
+      return getTextValue((schema as { name?: string }).name) || 'default';
+    case 'Article':
+    case 'BlogPosting':
+      return (
+        getTextValue((schema as Article | BlogPosting).headline) ||
+        'untitled-article'
+      );
+    case 'Product':
+      return getTextValue((schema as Product).name) || 'untitled-product';
+    case 'Person':
+      return (
+        getTextValue((schema as { name?: string }).name) || 'unnamed-person'
+      );
+    case 'Event':
+      return getTextValue((schema as Event).name) || 'untitled-event';
+    case 'LocalBusiness':
+      return (
+        getTextValue((schema as { name?: string }).name) || 'unnamed-business'
+      );
+    case 'Service':
+      return getTextValue((schema as Service).name) || 'unnamed-service';
+    case 'Brand':
+      return getTextValue((schema as Brand).name) || 'unnamed-brand';
+    case 'FAQPage':
+      return 'faq-page';
+    case 'HowTo':
+      return getTextValue((schema as HowTo).name) || 'untitled-howto';
+    case 'BreadcrumbList':
+      return 'breadcrumbs';
+    case 'Offer':
+      const offer = schema as Offer;
+      return `offer-${offer.price ?? 'unknown-price'}`;
+    case 'WebPage':
+      return getTextValue((schema as WebPage).name) || 'untitled-page';
path: src/components/SEOTags/SEOTags.tsx
user: login: Copilot
body: Consider simplifying the repetitive type casts in getSchemaIdentifier by leveraging optional chaining or extracting a helper function for accessing properties like 'name' or 'headline'.
```suggestion
// Helper function to safely access properties from a schema
const getSchemaProperty = (schema: SupportedSchemaType, property: string): string => {
  return getTextValue((schema as Record<string, unknown>)[property]) || '';
};

// Helper function to get a unique identifier from a schema
const getSchemaIdentifier = (schema: SupportedSchemaType): string => {
  const type = (schema as { '@type': string })['@type'];

  switch (type) {
    case 'Organization':
      return getSchemaProperty(schema, 'name') || 'default';
    case 'Article':
    case 'BlogPosting':
      return getSchemaProperty(schema, 'headline') || 'untitled-article';
    case 'Product':
      return getSchemaProperty(schema, 'name') || 'untitled-product';
    case 'Person':
      return getSchemaProperty(schema, 'name') || 'unnamed-person';
    case 'Event':
      return getSchemaProperty(schema, 'name') || 'untitled-event';
    case 'LocalBusiness':
      return getSchemaProperty(schema, 'name') || 'unnamed-business';
    case 'Service':
      return getSchemaProperty(schema, 'name') || 'unnamed-service';
    case 'Brand':
      return getSchemaProperty(schema, 'name') || 'unnamed-brand';
    case 'FAQPage':
      return 'faq-page';
    case 'HowTo':
      return getSchemaProperty(schema, 'name') || 'untitled-howto';
    case 'BreadcrumbList':
      return 'breadcrumbs';
    case 'Offer':
      const offer = schema as Offer;
      return `offer-${offer.price ?? 'unknown-price'}`;
    case 'WebPage':
      return getSchemaProperty(schema, 'name') || 'untitled-page';
```
created_at: 2025-05-27T11:35:19Z
author_association: NONE

PR comments:
user: login: vercel[bot]
created_at: 2025-05-26T20:42:30Z
body: [vc]: #YAQHhQfJnBT6uFPMaWGWLVfXB22uPeh7snAa4SpiggM=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJzdG9yeWJvb2siLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vdHJlZWx5L3N0b3J5Ym9vay83UjYxVzRYZEQ0TjlQTGc1ZVR5YmdzcEtnclpxIiwicHJldmlld1VybCI6InN0b3J5Ym9vay1naXQtcmJzLWFkZC1zY2hlbWEtbWFya3Vwcy10cmVlbHkudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJzdG9yeWJvb2stZ2l0LXJicy1hZGQtc2NoZW1hLW1hcmt1cHMtdHJlZWx5LnZlcmNlbC5hcHAifSwicm9vdERpcmVjdG9yeSI6bnVsbH1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **storybook** | ✅ Ready ([Inspect](https://vercel.com/treely/storybook/7R61W4XdD4N9PLg5eTybgspKgrZq)) | [Visit Preview](https://storybook-git-rbs-add-schema-markups-treely.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/storybook-git-rbs-add-schema-markups-treely.vercel.app?via=pr-comment-feedback-link) | May 28, 2025 7:41am |


author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-26T20:56:20Z
body: ## size-limit report 📦 
| Path                                     | Size                  |
| ---------------------------------------- | --------------------- |
| dist/strapi-slices.cjs.production.min.js | 1.75 MB (+0.05% 🔺)   |
| dist/strapi-slices.esm.js                | 556.29 KB (+0.13% 🔺) |
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-28T08:46:38Z
body: :tada: This PR is included in version 7.11.0 :tada:

The release is available on:
- [npm package (@latest dist-tag)](https://www.npmjs.com/package/@treely/strapi-slices/v/7.11.0)
- [GitHub release](https://github.com/treely/strapi-slices/releases/tag/v7.11.0)

Your **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:
author_association: NONE