Skip to content

Add StructuredData element for JSON-LD structured data#882

Open
jpurnell wants to merge 6 commits into
twostraws:mainfrom
jpurnell:feature/structured-data
Open

Add StructuredData element for JSON-LD structured data#882
jpurnell wants to merge 6 commits into
twostraws:mainfrom
jpurnell:feature/structured-data

Conversation

@jpurnell
Copy link
Copy Markdown
Contributor

Summary

Adds a new StructuredData head element that renders JSON-LD <script type="application/ld+json"> tags for Schema.org structured data.

  • Generic initializer — any schema type with a properties dictionary
  • Raw JSON-LD — pass a pre-formatted JSON string for full control
  • Convenience factoriesOrganization, WebSite, Event, Article, and BreadcrumbList with typed parameters
  • Article and BreadcrumbList auto-populate from the publishing context (article metadata, page URL, site URL)
  • Conforms to HeadElement and Sendable; 51 tests; DocC catalog entry

Usage

StructuredData is a HeadElement, so you use it inside a Head { } block in your Layout — the same place you'd put a MetaTag, MetaLink, or Script:

struct MyLayout: Layout {
    var body: some Document {
        Head {
            // Generic schema — any Schema.org type
            StructuredData("LocalBusiness", properties: [
                "name": "Joe's Pizza",
                "telephone": "555-0123"
            ])

            // Convenience methods
            StructuredData.organization(name: "Acme Corp", url: "https://acme.com")
            StructuredData.webSite(name: "Acme Corp", url: "https://acme.com")
            StructuredData.breadcrumbs()
            StructuredData.article(publisher: "Acme Corp", publisherURL: "https://acme.com")
            StructuredData.event(
                name: "Annual Conference",
                startDate: "2026-06-01",
                endDate: "2026-06-03",
                locationName: "Convention Center",
                locality: "Springfield",
                region: "IL",
                postalCode: "62701"
            )

            // Raw JSON-LD for schemas without a convenience method
            StructuredData(json: customJSONString)
        }

        Body {
            content
        }
    }
}

No site-level configuration is needed. Each StructuredData element renders an independent <script type="application/ld+json"> tag in the page's <head>, and you can include as many as you need per page. Context-aware schemas like Article and BreadcrumbList automatically emit nothing when they don't apply (e.g., breadcrumbs on the homepage, article schema on non-article pages).

Test plan

  • 51 tests covering all schema types, edge cases, unicode, invalid input, stress tests, and documentation examples
  • Builds clean on Swift 6.3 with strict concurrency (Sendable conformance, no @MainActor)
  • Tests use the .publishingContext() trait (current upstream pattern)
  • JSON-LD output validates at https://validator.schema.org on a live site

jpurnell added 6 commits May 19, 2026 15:01
Adds a new HeadElement that renders <script type="application/ld+json">
blocks. Supports both generic schema creation from a type + properties
dictionary, and convenience factories for common Schema.org types:

- .article() — auto-generates from current article YAML front matter
- .organization() — Organization schema
- .webSite() — WebSite schema
- .event() — Event schema with location and organizer
- .breadcrumbs() — auto-generates BreadcrumbList from page context

The generic initializer accepts any Schema.org type string and properties
dictionary, so users can create any schema type without modifying the
framework:

    StructuredData("Product", properties: ["name": "Widget", "price": "9.99"])
12 tests covering generic schemas, raw JSON, convenience methods
(Organization, WebSite, Event, BreadcrumbList, Article), optional
parameter handling, and custom context support.
Add article context testing via withEnvironment() helper, covering:
- Article with headline, date, description, author, image, publisher
- dateModified included/omitted based on whether it differs from date
- Relative vs absolute image URL handling
- Publisher with and without URL
- Fully populated article with all fields

Also adds tests for previously uncovered paths:
- Nested dictionary and array properties in generic schemas
- Organization with custom parentOrganizationType
- WebSite without description
- Event with custom status/attendanceMode and non-US country
- Breadcrumbs on homepage (emits nothing)
- Breadcrumbs with specific page title and URL
Aligns with TDD directive requirements:

Edge cases: special characters, unicode, empty raw JSON, empty sameAs,
deeply nested schemas (3 levels), HTML entities in article titles,
deeply nested page paths.

Invalid inputs: malformed JSON string (no crash), empty type string.

Property-based: output structure invariant (all non-empty outputs
wrapped in script tags), @context/@type present in all schema outputs,
article required fields (headline/datePublished/url), empty output
invariant for non-applicable contexts.

Stress: 500-property dictionary, 10,000-character description,
50 sameAs URLs.

Documentation verification: all three doc comment examples compile
and execute without error.

Total: 51 tests, 102 test cases.
Replace @mainactor isolation with Sendable conformance to match upstream
protocol requirements. Pre-serialize schema dictionaries at init time so
the Content enum is fully Sendable. Migrate all 51 tests from the
deprecated `arguments: await Self.sites` pattern to .publishingContext().
The two breadcrumb tests that didn't use withPageContext relied on the
default test environment having a non-root page URL. On CI the default
page is root, triggering the homepage guard and returning empty output.
Copy link
Copy Markdown
Collaborator

@MrSkwiggs MrSkwiggs left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the work done here, but I'm a bit torn with adding this and supporting this directly from within Ignite.

I'm starting to think it might actually make more sense to vend this as a separate plugin. That way we could make the plugin exhaustive and offer full parity with Schema.org without overloading Ignite directly?

Something akin to how Vapor registers additional functionality might be interesting to look into, but yeah... that'd require quite a significant rework to properly support.

/// StructuredData(json: customJSONString)
/// }
/// ```
public struct StructuredData: HeadElement, Sendable {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is specifically about JSON-LD which stands for Linked Data, maybe this makes more sense here?

Suggested change
public struct StructuredData: HeadElement, Sendable {
public struct LinkedData: HeadElement, Sendable {

or even

Suggested change
public struct StructuredData: HeadElement, Sendable {
public struct SchemaData: HeadElement, Sendable {

/// - sameAs: URLs for the organization's social media profiles or external pages.
/// - parentOrganization: An optional parent organization as (name, url).
/// - parentOrganizationType: The Schema.org type for the parent. Defaults to "Organization".
public static func organization(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we're providing a few convenience constructors here, would it make sense to provide full parity from the get-go? IMHO if we introduce this feature with limited support, it wouldn't be quite user-friendly.

Now with that said, peeking at the amount of different types there are, maybe we could support most top-level constructors at least?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants