# HTML Metadata Structure Changes (v4.0)

## Summary

HTML metadata has been restructured for better organization and type safety. The changes consolidate individual Open Graph and Twitter Card fields into maps, and convert keywords from a single string to an array.

## Breaking Changes

### 1. Keywords: String to Array

**Before (v3.x):**

```rust title="Keywords as Comma-Separated String"
// Option<String> - comma-separated or space-separated
html_meta.keywords  // "seo, metadata, html"
```

**After (v4.0):**

```rust title="Keywords as Structured Array"
// Vec<String> - structured array
html_meta.keywords  // vec!["seo", "metadata", "html"]
```

### 2. Canonical URL: Field Rename

**Before (v3.x):**

```rust title="Canonical Field (v3.x)"
html_meta.canonical  // Option<String>
```

**After (v4.0):**

```rust title="Canonical URL Field (v4.0)"
html_meta.canonical_url  // Option<String>
```

### 3. Open Graph: Individual Fields to Map

**Before (v3.x):**

```rust title="Open Graph as Individual Fields"
html_meta.og_title          // Option<String>
html_meta.og_description    // Option<String>
html_meta.og_image          // Option<String>
html_meta.og_url            // Option<String>
html_meta.og_type           // Option<String>
html_meta.og_site_name      // Option<String>
```

**After (v4.0):**

```rust title="Open Graph as Map Structure"
html_meta.open_graph        // BTreeMap<String, String>
html_meta.open_graph.get("title")         // Option<&String>
html_meta.open_graph.get("description")   // Option<&String>
html_meta.open_graph.get("image")         // Option<&String>
html_meta.open_graph.get("url")           // Option<&String>
html_meta.open_graph.get("type")          // Option<&String>
html_meta.open_graph.get("site_name")     // Option<&String>
```

### 4. Twitter Card: Individual Fields to Map

**Before (v3.x):**

```rust title="Twitter Card as Individual Fields"
html_meta.twitter_card          // Option<String>
html_meta.twitter_title         // Option<String>
html_meta.twitter_description   // Option<String>
html_meta.twitter_image         // Option<String>
html_meta.twitter_site          // Option<String>
html_meta.twitter_creator       // Option<String>
```

**After (v4.0):**

```rust title="Twitter Card as Map Structure"
html_meta.twitter_card          // BTreeMap<String, String>
html_meta.twitter_card.get("card")          // Option<&String>
html_meta.twitter_card.get("title")         // Option<&String>
html_meta.twitter_card.get("description")   // Option<&String>
html_meta.twitter_card.get("image")         // Option<&String>
html_meta.twitter_card.get("site")          // Option<&String>
html_meta.twitter_card.get("creator")       // Option<&String>
```

### 5. Removed Fields

The following link-related fields have been removed:

- `link_author`
- `link_license`
- `link_alternate`

Use the new `links` field instead for comprehensive link extraction.

### 6. New Fields

HTML metadata now includes rich metadata about page content:

- **`language`**: Document language (for example, "en", "fr")
- **`text_direction`**: Text direction ("ltr", "rtl")
- **`headers`**: List of page headers/headings with structured metadata
- **`links`**: List of links with detailed metadata and type classification
- **`images`**: List of images with alt text, dimensions, and type classification
- **`structured_data`**: Parsed JSON-LD, microdata, and RDFa data
- **`meta_tags`**: All meta tags as a map

## Migration Guide

### Rust

=== "Before (v3.x)"

    ```rust
    use kreuzberg::{extract_file_sync, ExtractionConfig};

    let result = extract_file_sync("page.html", None, &ExtractionConfig::default())?;
    if let Some(html_meta) = result.metadata.html {
        // Keywords as single string
        if let Some(keywords) = html_meta.keywords {
            let keyword_vec: Vec<&str> = keywords.split(',').map(|s| s.trim()).collect();
            println!("Keywords: {:?}", keyword_vec);
        }

        // Canonical as separate field
        if let Some(canonical) = html_meta.canonical {
            println!("Canonical: {}", canonical);
        }

        // Open Graph as individual fields
        if let Some(og_title) = html_meta.og_title {
            println!("OG Title: {}", og_title);
        }
        if let Some(og_image) = html_meta.og_image {
            println!("OG Image: {}", og_image);
        }

        // Twitter as individual fields
        if let Some(twitter_card) = html_meta.twitter_card {
            println!("Twitter Card: {}", twitter_card);
        }
    }
    ```

=== "After (v4.0)"

    ```rust
    use kreuzberg::{extract_file_sync, ExtractionConfig};

    let result = extract_file_sync("page.html", None, &ExtractionConfig::default())?;
    if let Some(html_meta) = result.metadata.html {
        // Keywords as array
        if !html_meta.keywords.is_empty() {
            println!("Keywords: {:?}", html_meta.keywords);
        }

        // Canonical renamed
        if let Some(canonical_url) = html_meta.canonical_url {
            println!("Canonical URL: {}", canonical_url);
        }

        // Open Graph from map
        if let Some(og_title) = html_meta.open_graph.get("title") {
            println!("OG Title: {}", og_title);
        }
        if let Some(og_image) = html_meta.open_graph.get("image") {
            println!("OG Image: {}", og_image);
        }

        // Twitter from map
        if let Some(twitter_card) = html_meta.twitter_card.get("card") {
            println!("Twitter Card: {}", twitter_card);
        }

        // New fields
        if let Some(lang) = html_meta.language {
            println!("Language: {}", lang);
        }
        if let Some(headers) = html_meta.headers {
            println!("Headers: {:?}", headers);
        }
        if let Some(links) = html_meta.links {
            for (url, text) in links {
                println!("Link: {} ({})", url, text);
            }
        }
    }
    ```

### Python

=== "Before (v3.x)"

    ```python
    from kreuzberg import extract_file_sync, ExtractionConfig

    result = extract_file_sync("page.html", config=ExtractionConfig())
    html_meta = result.metadata.get("html", {})

    # Keywords as single string
    if html_meta.get('keywords'):
        keyword_list = html_meta['keywords'].split(',')
        print(f"Keywords: {keyword_list}")

    # Canonical as separate field
    if html_meta.get('canonical'):
        print(f"Canonical: {html_meta['canonical']}")

    # Open Graph as individual fields
    if html_meta.get('og_title'):
        print(f"OG Title: {html_meta['og_title']}")
    if html_meta.get('og_image'):
        print(f"OG Image: {html_meta['og_image']}")

    # Twitter as individual fields
    if html_meta.get('twitter_card'):
        print(f"Twitter Card: {html_meta['twitter_card']}")
    ```

=== "After (v4.0)"

    ```python
    from kreuzberg import extract_file_sync, ExtractionConfig

    result = extract_file_sync("page.html", config=ExtractionConfig())
    html_meta = result.metadata.get("html", {})

    # Keywords as array
    if html_meta.get('keywords'):
        print(f"Keywords: {html_meta['keywords']}")

    # Canonical renamed
    if html_meta.get('canonical_url'):
        print(f"Canonical URL: {html_meta['canonical_url']}")

    # Open Graph from map
    open_graph = html_meta.get('open_graph', {})
    if open_graph.get('title'):
        print(f"OG Title: {open_graph['title']}")
    if open_graph.get('image'):
        print(f"OG Image: {open_graph['image']}")

    # Twitter from map
    twitter_card = html_meta.get('twitter_card', {})
    if twitter_card.get('card'):
        print(f"Twitter Card: {twitter_card['card']}")

    # New fields
    if html_meta.get('language'):
        print(f"Language: {html_meta['language']}")

    if html_meta.get('headers'):
        print(f"Headers: {html_meta['headers']}")

    if html_meta.get('links'):
        for url, text in html_meta['links']:
            print(f"Link: {url} ({text})")
    ```

### TypeScript

=== "Before (v3.x)"

    ```typescript
    import { extractFileSync } from '@kreuzberg/node';

    const result = extractFileSync('page.html');
    const htmlMeta = result.metadata;

    // Keywords as single string
    if (htmlMeta.keywords) {
        const keywordArray = htmlMeta.keywords.split(',');
        console.log('Keywords:', keywordArray);
    }

    // Canonical as separate field
    if (htmlMeta.canonical) {
        console.log('Canonical:', htmlMeta.canonical);
    }

    // Open Graph as individual fields
    if (htmlMeta.ogTitle) {
        console.log('OG Title:', htmlMeta.ogTitle);
    }
    if (htmlMeta.ogImage) {
        console.log('OG Image:', htmlMeta.ogImage);
    }

    // Twitter as individual fields
    if (htmlMeta.twitterCard) {
        console.log('Twitter Card:', htmlMeta.twitterCard);
    }
    ```

=== "After (v4.0)"

    ```typescript
    import { extractFileSync } from '@kreuzberg/node';

    const result = extractFileSync('page.html');
    const htmlMeta = result.metadata;

    // Keywords as array
    if (htmlMeta.keywords?.length > 0) {
        console.log('Keywords:', htmlMeta.keywords);
    }

    // Canonical renamed
    if (htmlMeta.canonicalUrl) {
        console.log('Canonical URL:', htmlMeta.canonicalUrl);
    }

    // Open Graph from map
    if (htmlMeta.openGraph) {
        if (htmlMeta.openGraph['title']) {
            console.log('OG Title:', htmlMeta.openGraph['title']);
        }
        if (htmlMeta.openGraph['image']) {
            console.log('OG Image:', htmlMeta.openGraph['image']);
        }
    }

    // Twitter from map
    if (htmlMeta.twitterCard) {
        if (htmlMeta.twitterCard['card']) {
            console.log('Twitter Card:', htmlMeta.twitterCard['card']);
        }
    }

    // New fields
    if (htmlMeta.language) {
        console.log('Language:', htmlMeta.language);
    }

    if (htmlMeta.headers?.length > 0) {
        console.log('Headers:', htmlMeta.headers);
    }

    if (htmlMeta.links?.length > 0) {
        htmlMeta.links.forEach(([url, text]) => {
            console.log(`Link: ${url} (${text})`);
        });
    }
    ```

### Java

=== "Before (v3.x)"

    ```java
    import dev.kreuzberg.Kreuzberg;
    import dev.kreuzberg.ExtractionResult;
    import java.util.Map;

    ExtractionResult result = Kreuzberg.extractFileSync("page.html");
    Map<String, Object> htmlMeta = (Map<String, Object>) result.getMetadata().get("html");

    // Keywords as single string
    String keywords = (String) htmlMeta.get("keywords");
    if (keywords != null) {
        String[] keywordArray = keywords.split(",");
        System.out.println("Keywords: " + Arrays.toString(keywordArray));
    }

    // Canonical as separate field
    String canonical = (String) htmlMeta.get("canonical");
    if (canonical != null) {
        System.out.println("Canonical: " + canonical);
    }

    // Open Graph as individual fields
    String ogTitle = (String) htmlMeta.get("og_title");
    if (ogTitle != null) {
        System.out.println("OG Title: " + ogTitle);
    }

    // Twitter as individual fields
    String twitterCard = (String) htmlMeta.get("twitter_card");
    if (twitterCard != null) {
        System.out.println("Twitter Card: " + twitterCard);
    }
    ```

=== "After (v4.0)"

    ```java
    import dev.kreuzberg.Kreuzberg;
    import dev.kreuzberg.ExtractionResult;
    import java.util.Map;
    import java.util.List;

    ExtractionResult result = Kreuzberg.extractFileSync("page.html");
    Map<String, Object> htmlMeta = (Map<String, Object>) result.getMetadata().get("html");

    // Keywords as array
    @SuppressWarnings("unchecked")
    List<String> keywords = (List<String>) htmlMeta.get("keywords");
    if (keywords != null && !keywords.isEmpty()) {
        System.out.println("Keywords: " + keywords);
    }

    // Canonical renamed
    String canonicalUrl = (String) htmlMeta.get("canonical_url");
    if (canonicalUrl != null) {
        System.out.println("Canonical URL: " + canonicalUrl);
    }

    // Open Graph from map
    @SuppressWarnings("unchecked")
    Map<String, String> openGraph = (Map<String, String>) htmlMeta.get("open_graph");
    if (openGraph != null) {
        String ogTitle = openGraph.get("title");
        if (ogTitle != null) {
            System.out.println("OG Title: " + ogTitle);
        }
    }

    // Twitter from map
    @SuppressWarnings("unchecked")
    Map<String, String> twitterCard = (Map<String, String>) htmlMeta.get("twitter_card");
    if (twitterCard != null) {
        String card = twitterCard.get("card");
        if (card != null) {
            System.out.println("Twitter Card: " + card);
        }
    }

    // New fields
    String language = (String) htmlMeta.get("language");
    if (language != null) {
        System.out.println("Language: " + language);
    }

    @SuppressWarnings("unchecked")
    List<String> headers = (List<String>) htmlMeta.get("headers");
    if (headers != null && !headers.isEmpty()) {
        System.out.println("Headers: " + headers);
    }
    ```

### Go

=== "Before (v3.x)"

    ```go
    package main

    import (
        "fmt"
        "log"
        "strings"
        "github.com/kreuzberg-dev/kreuzberg/packages/go/v5"
    )

    func main() {
        result, err := kreuzberg.ExtractFileSync("page.html", nil)
        if err != nil {
            log.Fatalf("extract: %v", err)
        }

        if html, ok := result.Metadata.HTMLMetadata(); ok {
            // Keywords as single string
            if html.Keywords != nil {
                keywordSlice := strings.Split(*html.Keywords, ",")
                fmt.Println("Keywords:", keywordSlice)
            }

            // Canonical as separate field
            if html.Canonical != nil {
                fmt.Println("Canonical:", *html.Canonical)
            }

            // Open Graph as individual fields
            if html.OGTitle != nil {
                fmt.Println("OG Title:", *html.OGTitle)
            }
            if html.OGImage != nil {
                fmt.Println("OG Image:", *html.OGImage)
            }

            // Twitter as individual fields
            if html.TwitterCard != nil {
                fmt.Println("Twitter Card:", *html.TwitterCard)
            }
        }
    }
    ```

=== "After (v4.0)"

    ```go
    package main

    import (
        "fmt"
        "log"
        "strings"
        "github.com/kreuzberg-dev/kreuzberg/packages/go/v5"
    )

    func main() {
        result, err := kreuzberg.ExtractFileSync("page.html", nil)
        if err != nil {
            log.Fatalf("extract: %v", err)
        }

        if html, ok := result.Metadata.HTMLMetadata(); ok {
            // Keywords as array
            if len(html.Keywords) > 0 {
                fmt.Println("Keywords:", strings.Join(html.Keywords, ", "))
            }

            // Canonical renamed
            if html.CanonicalURL != nil {
                fmt.Println("Canonical URL:", *html.CanonicalURL)
            }

            // Open Graph from map
            if len(html.OpenGraph) > 0 {
                if ogTitle, ok := html.OpenGraph["title"]; ok {
                    fmt.Println("OG Title:", ogTitle)
                }
                if ogImage, ok := html.OpenGraph["image"]; ok {
                    fmt.Println("OG Image:", ogImage)
                }
            }

            // Twitter from map
            if len(html.TwitterCard) > 0 {
                if card, ok := html.TwitterCard["card"]; ok {
                    fmt.Println("Twitter Card:", card)
                }
            }

            // New fields
            if html.Language != nil {
                fmt.Println("Language:", *html.Language)
            }

            if len(html.Headers) > 0 {
                fmt.Println("Headers:", strings.Join(html.Headers, ", "))
            }

            if len(html.Links) > 0 {
                for _, link := range html.Links {
                    fmt.Printf("Link: %s (%s)\n", link[0], link[1])
                }
            }
        }
    }
    ```

### Ruby

=== "Before (v3.x)"

    ```ruby
    require 'kreuzberg'

    result = Kreuzberg.extract_file_sync('page.html')
    html_meta = result.metadata['html']

    # Keywords as single string
    if html_meta['keywords']
        keyword_array = html_meta['keywords'].split(',').map(&:strip)
        puts "Keywords: #{keyword_array}"
    end

    # Canonical as separate field
    if html_meta['canonical']
        puts "Canonical: #{html_meta['canonical']}"
    end

    # Open Graph as individual fields
    if html_meta['og_title']
        puts "OG Title: #{html_meta['og_title']}"
    end
    if html_meta['og_image']
        puts "OG Image: #{html_meta['og_image']}"
    end

    # Twitter as individual fields
    if html_meta['twitter_card']
        puts "Twitter Card: #{html_meta['twitter_card']}"
    end
    ```

=== "After (v4.0)"

    ```ruby
    require 'kreuzberg'

    result = Kreuzberg.extract_file_sync('page.html')
    html_meta = result.metadata['html']

    # Keywords as array
    if html_meta['keywords'] && !html_meta['keywords'].empty?
        puts "Keywords: #{html_meta['keywords']}"
    end

    # Canonical renamed
    if html_meta['canonical_url']
        puts "Canonical URL: #{html_meta['canonical_url']}"
    end

    # Open Graph from map
    open_graph = html_meta['open_graph'] || {}
    if open_graph['title']
        puts "OG Title: #{open_graph['title']}"
    end
    if open_graph['image']
        puts "OG Image: #{open_graph['image']}"
    end

    # Twitter from map
    twitter_card = html_meta['twitter_card'] || {}
    if twitter_card['card']
        puts "Twitter Card: #{twitter_card['card']}"
    end

    # New fields
    if html_meta['language']
        puts "Language: #{html_meta['language']}"
    end

    if html_meta['headers'] && !html_meta['headers'].empty?
        puts "Headers: #{html_meta['headers'].join(', ')}"
    end

    if html_meta['links'] && !html_meta['links'].empty?
        html_meta['links'].each do |url, text|
            puts "Link: #{url} (#{text})"
        end
    end
    ```

## API Reference

For complete details on all HTML metadata fields and types, see:

- [HTML Metadata Type Reference](../reference/types.md#htmlmetadata)

## Structured Types Reference

### HeaderMetadata

Header elements extracted from the HTML document with hierarchy information.

```rust title="HeaderMetadata Struct Definition"
pub struct HeaderMetadata {
    pub level: u8,                    // 1-6 (h1-h6)
    pub text: String,                // Normalized text content
    pub id: Option<String>,           // HTML id attribute
    pub depth: usize,                 // Document tree depth
    pub html_offset: usize,           // Byte offset in original HTML
}
```

**Example:**

```json title="HeaderMetadata JSON Example"
{
  "level": 1,
  "text": "Welcome to Our Site",
  "id": "welcome-section",
  "depth": 2,
  "html_offset": 512
}
```

### LinkMetadata

Link elements with type classification and detailed attributes.

```rust title="LinkMetadata Struct and LinkType Enum"
pub struct LinkMetadata {
    pub href: String,                        // The href URL value
    pub text: String,                        // Link text content
    pub title: Option<String>,               // Title attribute
    pub link_type: LinkType,                 // Classification enum
    pub rel: Vec<String>,                    // Rel attribute values
    pub attributes: HashMap<String, String>, // Additional attributes
}

pub enum LinkType {
    Anchor,    // #section anchors
    Internal,  // Same domain links
    External,  // Different domain links
    Email,     // mailto: links
    Phone,     // tel: links
    Other,     // Other link types
}
```

**Example:**

```json title="LinkMetadata JSON Example"
{
  "href": "https://example.com",
  "text": "Visit Example",
  "title": "Example Website",
  "link_type": "external",
  "rel": ["nofollow"],
  "attributes": {
    "data-tracking": "yes"
  }
}
```

### ImageMetadataType

Image elements with type classification and dimensions.

```rust title="ImageMetadataType Struct and ImageType Enum"
pub struct ImageMetadataType {
    pub src: String,                         // Image source (URL, data URI, or SVG)
    pub alt: Option<String>,                 // Alt text
    pub title: Option<String>,               // Title attribute
    pub dimensions: Option<(u32, u32)>,      // Width x Height
    pub image_type: ImageType,               // Classification enum
    pub attributes: HashMap<String, String>, // Additional attributes
}

pub enum ImageType {
    DataUri,    // data: URI
    InlineSvg,  // Inline <svg> content
    External,   // External URL
    Relative,   // Relative path
}
```

**Example:**

```json title="ImageMetadataType JSON Example"
{
  "src": "https://cdn.example.com/image.jpg",
  "alt": "Product photo",
  "title": "Featured product",
  "dimensions": [400, 300],
  "image_type": "external",
  "attributes": {
    "loading": "lazy"
  }
}
```

### StructuredData

Extracted structured data blocks (JSON-LD, microdata, RDFa).

```rust title="StructuredData Struct and StructuredDataType Enum"
pub struct StructuredData {
    pub data_type: StructuredDataType,  // Classification enum
    pub raw_json: String,               // Raw JSON string
    pub schema_type: Option<String>,    // Schema type (e.g., "Article")
}

pub enum StructuredDataType {
    JsonLd,   // JSON-LD
    Microdata, // microdata
    RDFa,     // RDFa
}
```

**Example:**

```json title="StructuredData JSON Example"
{
  "data_type": "json-ld",
  "raw_json": "{\"@context\": \"https://schema.org\", \"@type\": \"Article\", ...}",
  "schema_type": "Article"
}
```

## Summary of Changes

| Field                                           | v3.x                               | v4.0                                                |
| ----------------------------------------------- | ---------------------------------- | --------------------------------------------------- |
| `keywords`                                      | `Option<String>`                   | `Vec<String>` with `#[serde(default)]`              |
| `canonical`                                     | `Option<String>`                   | Renamed to `canonical_url`                          |
| `og_*` fields (7 fields)                        | Individual `Option<String>` fields | `open_graph: BTreeMap<String, String>`              |
| `twitter_*` fields (6 fields)                   | Individual `Option<String>` fields | `twitter_card: BTreeMap<String, String>`            |
| `link_author`, `link_license`, `link_alternate` | Individual fields                  | Removed (use `links` field)                         |
| New: `language`                                 | N/A                                | `Option<String>`                                    |
| New: `text_direction`                           | N/A                                | `Option<TextDirection>`                             |
| New: `headers`                                  | N/A                                | `Vec<HeaderMetadata>` with `#[serde(default)]`      |
| New: `links`                                    | N/A                                | `Vec<LinkMetadata>` with `#[serde(default)]`        |
| New: `images`                                   | N/A                                | `Vec<ImageMetadataType>` with `#[serde(default)]`   |
| New: `structured_data`                          | N/A                                | `Vec<StructuredData>` with `#[serde(default)]`      |
| New: `meta_tags`                                | N/A                                | `BTreeMap<String, String>` with `#[serde(default)]` |

## Questions?

- See the [Types Reference](../reference/types.md) for complete API details
- Check [Working with Metadata](../getting-started/quickstart.md#read-document-metadata) for examples
- Open an issue on [GitHub](https://github.com/kreuzberg-dev/kreuzberg/issues)
