Skip to content

Add jsonld schema script #31

Description

@edwintantawi

RFC: SchemaOrg Builder for JSON-LD Structured Data

Summary

A type-safe builder pattern for creating Schema.org structured data with support for entity relationships, URL resolution, and flexible output formats.

Motivation

Schema.org structured data is essential for SEO and rich search results, but working with it has several challenges:

  1. Type Safety: Raw JSON-LD lacks compile-time type checking, leading to runtime errors
  2. Entity Relationships: Managing references between entities (e.g., Product → Brand) is error-prone
  3. URL Management: Handling relative vs absolute URLs and base URL resolution requires boilerplate
  4. Graph Complexity: Deciding between single entity output vs @graph wrapper is manual
  5. Developer Experience: Building complex schemas requires repetitive code

Detailed Design

Core Components

1. Entity Class

Represents a single Schema.org entity with its properties.

class Entity<TSchema extends BaseSchemaOptions = BaseSchemaOptions> {
  private properties: TSchema;

  getID(): string | undefined;
  getProperties(): TSchema;
}

Responsibilities:

  • Store schema properties
  • Provide type-safe access to @id field
  • Expose complete properties for serialization

2. SchemaOrg Class

Main builder class for constructing Schema.org graphs.

class SchemaOrg<
  TSchemaOptions extends BaseSchemaOptions,
  TKeys extends string,
  TGraph extends Record<string, Entity>
>

Generic Parameters:

  • TSchemaOptions: Union type of allowed schema types (e.g., Brand | Product)
  • TKeys: Accumulated string literal type of registered keys (prevents duplicates)
  • TGraph: Mapped type of the entity graph for cross-referencing

Key Methods:

add<TSchema, TKey>(key, value)

Adds a new entity to the graph with compile-time duplicate key prevention.

// Static value
.add('brand1', {
  '@type': 'Brand',
  name: 'Example'
})

// Dynamic value with graph references
.add('product1', (ref, helper) => ({
  '@type': 'Product',
  brand: { '@id': ref.brand1.getID() },
  url: helper.resolveUrl('/products/1')
}))

Type Safety Features:

  • TKey extends TKeys ? never : TKey prevents duplicate keys at compile time
  • Return type updates to include new key in TKeys union
  • Graph type expands to include new entity for future references
build(): string

Serializes the graph to JSON-LD string.

Smart Output Logic:

  • Single entity: Outputs without @graph wrapper
  • Multiple entities: Wraps in @graph array
// Single entity output
{
  "@context": "https://schema.org",
  "@type": "Brand",
  "name": "Example"
}

// Multiple entities output
{
  "@context": "https://schema.org",
  "@graph": [
    { "@type": "Brand", "@id": "#brand1" },
    { "@type": "Product", "brand": { "@id": "#brand1" } }
  ]
}

URL Resolution

The resolveUrl helper handles three cases:

  1. URL Object: Returns href directly
  2. No Base URL: Returns string as-is (useful for external URLs)
  3. Relative URL: Resolves against base URL using new URL(url, baseUrl)
private resolveUrl(url: string | URL): string {
  if (url instanceof URL) return url.href;
  if (!this.baseUrl) return url;
  return new URL(url, this.baseUrl).href;
}

Helper Interface

Passed to dynamic value functions:

interface Helper {
  resolveUrl: (url: string | URL) => string;
}

Design Rationale:

  • Extensible: Can add more helpers without breaking existing code
  • Scoped: Only exposes safe, intentional utilities
  • Bound: Helper methods are pre-bound to SchemaOrg instance

Usage Examples

Basic Single Entity

const schema = new SchemaOrg<Brand>().add('brand', {
  '@type': 'Brand',
  name: 'Example Brand',
});

schema.build();
// Output: { "@context": "https://schema.org", "@type": "Brand", ... }

Entity Relationships

const schema = new SchemaOrg<Brand | Product>(new URL('https://example.com'))
  .add('brand', {
    '@type': 'Brand',
    '@id': '/brand',
    name: 'Example Brand',
  })
  .add('product', (ref, helper) => ({
    '@type': 'Product',
    '@id': helper.resolveUrl('/product'),
    name: 'Example Product',
    brand: { '@id': ref.brand.getID() }, // Type-safe reference!
  }));

Complex Graph

const schema = new SchemaOrg<Organization | WebSite | WebPage>(
  new URL('https://example.com'),
)
  .add('organization', {
    '@type': 'Organization',
    '@id': '/#organization',
    name: 'Example Corp',
  })
  .add('website', (ref) => ({
    '@type': 'WebSite',
    '@id': '/#website',
    publisher: { '@id': ref.organization.getID() },
  }))
  .add('webpage', (ref, h) => ({
    '@type': 'WebPage',
    '@id': h.resolveUrl('/about'),
    isPartOf: { '@id': ref.website.getID() },
  }));

Implementation Considerations

Type Safety Trade-offs

Pros:

  • Compile-time duplicate key detection
  • Type-safe entity references
  • IntelliSense support for graph navigation

Cons:

  • Complex generic signatures
  • TypeScript inference can struggle with deep nesting

Alternatives Considered

1. Plain Object Builder

const schema = {
  '@context': 'https://schema.org',
  '@type': 'Product',
  brand: { '@id': brandId }, // No type safety, manual tracking
};

Rejected because: No type safety, no relationship management, manual URL handling

2. Class-based Entities

class Product {
  constructor() {
    this.type = 'Product';
  }
  setBrand(brand: Brand) {
    /* ... */
  }
}

Rejected because: Verbose, OOP overhead, less flexible for dynamic data

3. Schema Factory Functions

createProduct({
  brand: createBrand({ name: 'Example' }),
});

Rejected because: No entity reuse, harder to manage relationships, less composable

Open Questions

  1. Validation: Should we validate against Schema.org definitions at build time?
  2. Serialization Options: Support pretty-printing or minification options?
  3. Default IDs: Auto-generate @id values if not provided?
  4. Mutation: Should graph be mutable with update() or remove() methods?
  5. Async Loading: Support for fetching remote context definitions?

Migration Path

This is a new feature, no migration needed.

For future versions:

  • Keep Entity and SchemaOrg as public exports
  • Consider deprecation warnings if changing API surface
  • Provide codemods for breaking changes

Future Enhancements

  1. Built-in Schemas: Pre-typed common schemas (Article, Person, etc.)
  2. Validation: Runtime validation against Schema.org definitions
  3. Serialization Formats: Support RDFa, Microdata in addition to JSON-LD
  4. DevTools: Browser extension for visualizing schema graphs
  5. Testing Utilities: Matchers for asserting schema structure

References

Appendix: Complete Type Signatures

interface BaseSchemaOptions {
  '@type': string;
  '@id'?: string;
  [key: string]: any;
}

interface Helper {
  resolveUrl: (url: string | URL) => string;
}

class Entity<TSchema extends BaseSchemaOptions> {
  constructor(properties: TSchema);
  getID(): TSchema['@id'];
  getProperties(): TSchema;
}

class SchemaOrg<
  TSchemaOptions extends BaseSchemaOptions = BaseSchemaOptions,
  TKeys extends string = never,
  TGraph extends Record<string, Entity> = {},
> {
  constructor(baseUrl?: URL);

  add<TSchema extends TSchemaOptions, TKey extends string>(
    key: TKey extends TKeys ? never : TKey,
    value: TSchema | ((ref: TGraph, helper: Helper) => TSchema),
  ): SchemaOrg<
    TSchemaOptions,
    TKeys | TKey,
    TGraph & Record<TKey, Entity<TSchema>>
  >;

  build(): string;
}

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or requestquestionFurther information is requested

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions