Guide / current binding

Use IA² with HTML

Publish identities, relationships, provenance, and constraints in the same HTML a person reads. No JSON-LD payload, remote context, inherited parser state, or script execution is needed to extract the graph. In the complementary direction, RDF/HTML describes a parsed DOM in Turtle or TriG and derives an inert, inspectable HTML document.

  • One elementcarries one complete statement.
  • Native HTMLcarries literal and IRI objects.
  • Local extractionyields a complete RDF 1.2 dataset.
  • Reverse renderingderives HTML while preserving unused RDF.

This is the friendly tour. For exact processing, conformance, recovery, and RDF 1.2 rules, use the normative HTML/RDF model.

Add statements, not a second document.

Any element with rdf-predicate is a statement carrier. That element locally determines a subject, predicate, object, and optional graph name. Nesting is useful HTML, but it never supplies hidden RDF state.

Subject
The element itself by default, or an explicit IRI or local blank-node key.
Predicate
A full IRI reference in rdf-predicate.
Object
Visible text, a native HTML value, a native IRI, a local key, or a direct child template.
Graph
The default graph unless rdf-graph or rdf-graph-key is present.

Use full IRI references. Core 0.1 has no prefix declarations or compact-IRI expansion. Write https://schema.org/name, not schema:name, in an HTML attribute.

Publish two facts in ordinary HTML.

Declare the binding on html, give the document a stable canonical location, and annotate the elements already carrying the information.

HTML
<!doctype html>
<html lang="en" rdf-version="1.2">
  <head>
    <link rel="canonical" href="https://example.com/notes">
  </head>
  <body>
    <article id="note-7">
      <h1
        rdf-subject="#note-7"
        rdf-predicate="https://schema.org/headline"
      >A field note</h1>

      <time
        datetime="2026-07-15"
        rdf-subject="#note-7"
        rdf-predicate="https://schema.org/dateCreated"
        rdf-datatype="http://www.w3.org/2001/XMLSchema#date"
      >July 15, 2026</time>
    </article>
  </body>
</html>
Yields <https://example.com/notes#note-7> <https://schema.org/headline> "A field note" ; <https://schema.org/dateCreated> "2026-07-15"^^<http://www.w3.org/2001/XMLSchema#date> .

The article is addressable because it has an id. It does not assert anything by itself. Each child statement explicitly names #note-7, so moving either child elsewhere in the same document cannot silently change what it describes.

Let the carrier describe itself, until it should not.

Subject construction follows one fixed order. An explicit rdf-subject wins, then rdf-subject-key, then the carrier’s own non-empty id. With none of those, the carrier gets a fresh blank node for that extraction.

Self-subject
<h1
  id="alice"
  rdf-predicate="https://schema.org/name"
>Alice</h1>
<#alice> <https://schema.org/name> "Alice" .
Describe another element
<article id="alice">
  <h1
    rdf-subject="#alice"
    rdf-predicate="https://schema.org/name"
  >Alice</h1>
</article>
<#alice> <https://schema.org/name> "Alice" .

There is no subject inheritance. A parent id makes the parent referable; it does not become the child’s RDF subject unless the child says so.

Use the HTML value that already means the thing.

IA² does not add a general rdf-object mini-language. The object comes from one native carrier on the statement element.

Object kindAuthoring formTypical use
Visible literalElement textNames, descriptions, labels
Exact literalmeta[content], data[value], time[datetime]Dates, codes, machine-exact values
IRINative href, src, cite, or other allowed single-IRI carrierLinks to people, artifacts, sources
Reusable blank noderdf-object-keyStructured anonymous resources
RDF 1.2 triple termOne direct child templateClaims about an unasserted proposition

IRI objects stay native links

HTML
<a
  href="https://orcid.org/0000-0002-1825-0097"
  rdf-subject="#alice"
  rdf-predicate="https://schema.org/sameAs"
>Alice’s ORCID</a>
<#alice> <https://schema.org/sameAs> <https://orcid.org/0000-0002-1825-0097> .

Keys correlate anonymous structure

HTML
<span
  rdf-subject="#ticket-42"
  rdf-predicate="https://schema.org/location"
  rdf-object-key="location"
></span>

<span
  rdf-subject-key="location"
  rdf-predicate="https://schema.org/name"
>Lab 3</span>
<#ticket-42> <https://schema.org/location> _:location . _:location <https://schema.org/name> "Lab 3" .

The source key is only an extraction-local correlation handle. A serializer chooses its own blank-node label.

Put datatype, language, and direction where they apply.

rdf-datatype gives a literal datatype. Native lang and dir give language and base direction, but only when directly present on the statement carrier.

HTML
<time
  datetime="2026-07-15"
  rdf-subject="#release"
  rdf-predicate="https://schema.org/datePublished"
  rdf-datatype="http://www.w3.org/2001/XMLSchema#date"
>15 July 2026</time>

<p
  lang="fr"
  rdf-subject="#release"
  rdf-predicate="https://schema.org/name"
>Architecture de l’information</p>
<#release> <https://schema.org/datePublished> "2026-07-15"^^<http://www.w3.org/2001/XMLSchema#date> ; <https://schema.org/name> "Architecture de l’information"@fr .

Literal metadata never inherits for RDF. An ancestor’s lang still affects HTML presentation and accessibility, but it does not silently change an extracted literal. Datatype and language are mutually exclusive.

Name graphs locally. Quote propositions without asserting them.

Named graphs

Put rdf-graph or rdf-graph-key directly on every statement that belongs to that graph. A non-statement element carrying the attribute declares an empty named graph.

HTML
<data
  value="approved"
  rdf-subject="#decision-9"
  rdf-predicate="https://schema.org/status"
  rdf-graph="#audit"
></data>

<span hidden rdf-graph="#empty-review-queue"></span>

RDF 1.2 triple terms

A direct child template can carry one unasserted statement as the outer statement’s object. Template contents live in a separate DocumentFragment, so the inner proposition is not accidentally asserted by a document-wide DOM query.

HTML
<div
  rdf-subject="#review"
  rdf-predicate="https://example.com/quotes"
>
  <template>
    <a
      href="https://example.com/bob"
      rdf-subject="https://example.com/alice"
      rdf-predicate="https://schema.org/knows"
    >Bob</a>
  </template>
</div>
<#review> <https://example.com/quotes> << <https://example.com/alice> <https://schema.org/knows> <https://example.com/bob> >> .

Extract the document you actually mean.

A downloaded HTML document is an inert observation. An application-mutated DOM is a runtime observation. IA² permits both. Each extraction is a snapshot of the supplied Document or DocumentFragment; extraction itself executes nothing.

Add the document navigator

The browser bundle mounts one navigator, extracts the current DOM, and refreshes after semantic mutations. This guide includes it, so its own RDF is available from the launcher on this page.

HTML
<script
  type="module"
  src="https://ia2.dev/packages/html-rdf-navigator/dist/html-rdf-navigator.js"
></script>
  • Inert acquisition: parse downloaded HTML and extract without running its scripts.
  • Live acquisition: extract the current light DOM after the host application changes it.
  • Separate fragments: pass a template’s content explicitly when that inert fragment is the intended root.

Start with the parser your stack already trusts.

Choose a language, open its source, and follow one small end-to-end example: fetch inert HTML, parse it as HTML5, find statement carriers by attribute name, and turn their native HTML values into RDF terms. No RDFa parser or script execution is involved.

Each version resolves the document’s canonical IRI and shows four representative objects: visible text, a relative link, an exact date, and a direction-aware language string. The examples use one compact HTML/RDF document so the foldouts make library and language idioms easy to compare. The smallest useful discovery query is [rdf-predicate], or //*[@rdf-predicate] with XPath.

The element-to-value choices are not repeated by hand in every example. A versioned RDF binding profile names each native carrier and its transformation. A small build step compiles that dictionary into the native table imported by each foldout. Applications that do not need RDF profile loading can use the equivalent neutral JSON table. Neither form requires SPARQL at runtime.

The dictionary is policy; the example is the adapter. The generated table answers which attribute carries a value and whether it is an IRI or literal. The readable source in each foldout still shows how that language fetches HTML, queries by attribute name, crosses template boundaries, resolves IRIs, normalizes fallback text, and constructs terms.

JavaScript / TypeScriptFetch: fetchParser: jsdomView source
javascript-typescript/extract.mjsGitHub
import { JSDOM } from "jsdom";
import { CARRIER_RULES } from "./carrier-profile.mjs";

const XSD_STRING = "http://www.w3.org/2001/XMLSchema#string";
const RDF_DIR_LANG_STRING = "http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString";

// Fetch and parse the document with its retrieval URL as the DOM base URL.
const response = await fetch(process.argv[2]);
if (!response.ok) {
  throw new Error(`HTTP ${response.status}`);
}
const html = await response.text();
const document = new JSDOM(html, { url: response.url }).window.document;

// HTML/RDF resolves semantic references against the one canonical source IRI.
const canonical = [...document.head.querySelectorAll("link[rel][href]")]
  .filter((link) => link.relList.contains("canonical"));
if (canonical.length !== 1) {
  throw new Error("Expected one canonical link");
}
const sourceIri = new URL(canonical[0].getAttribute("href"), response.url).href;

// A normal attribute selector finds every locally complete statement carrier.
const rows = [...document.querySelectorAll("[rdf-predicate]")]
  .filter((element) => !element.parentElement?.closest("template"))
  .map((element) => {
    const subject = new URL(element.getAttribute("rdf-subject"), sourceIri).href;
    const predicate = new URL(element.getAttribute("rdf-predicate"), sourceIri).href;

    // The generated profile identifies any native attribute that carries the value.
    const carriers = CARRIER_RULES.filter((rule) =>
      rule.element === element.localName && element.hasAttribute(rule.attribute));
    if (carriers.length > 1) {
      throw new Error(`Conflicting value carriers on <${element.localName}>`);
    }
    const carrier = carriers[0];

    // IRI carrier values are references; resolve them against the semantic source.
    if (carrier?.kind === "iri") {
      const object = new URL(element.getAttribute(carrier.attribute), sourceIri).href;
      return [subject, predicate, "I", object, "", "", ""];
    }

    // Literal carriers are exact strings. Other elements use normalized visible text.
    const value = carrier?.kind === "literal"
      ? element.getAttribute(carrier.attribute)
      : element.textContent.replace(/[\t\n\f\r ]+/g, " ").trim();

    // lang and dir apply only when they are directly present on this carrier.
    const language = element.getAttribute("lang") ?? "";
    const rawDirection = element.getAttribute("dir")?.toLowerCase();
    const direction = ["ltr", "rtl"].includes(rawDirection)
      ? rawDirection
      : "";

    // An explicit datatype wins; direction otherwise selects rdf:dirLangString.
    let datatype = direction ? RDF_DIR_LANG_STRING : XSD_STRING;
    if (element.hasAttribute("rdf-datatype")) {
      datatype = new URL(element.getAttribute("rdf-datatype"), sourceIri).href;
    }
    return [subject, predicate, "L", value, datatype, language, direction];
  })
  .sort((left, right) => left[1].localeCompare(right[1]));

process.stdout.write(`${rows.map((row) => row.join("\t")).join("\n")}\n`);
PythonFetch: HTTPXParser: html5lib + lxmlView source
python/extract.pyGitHub
from __future__ import annotations

import re
import sys
from urllib.parse import urljoin

import html5lib
import httpx
from carrier_profile import CARRIER_RULES

XSD_STRING = "http://www.w3.org/2001/XMLSchema#string"
RDF_DIR_LANG_STRING = "http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString"


def local_name(element) -> str:
    return element.tag.rsplit("}", 1)[-1]


# Fetch and parse with the HTML5 tree builder rather than an XML parser.
response = httpx.get(sys.argv[1], follow_redirects=True, timeout=10)
response.raise_for_status()
document = html5lib.parse(
    response.content,
    treebuilder="lxml",
    namespaceHTMLElements=True,
    transport_encoding=response.encoding,
)

# HTML/RDF resolves semantic references against the one canonical source IRI.
canonical = [
    element
    for element in document.xpath("//*[local-name()='head']/*[local-name()='link'][@rel][@href]")
    if "canonical" in element.get("rel", "").lower().split()
]
if len(canonical) != 1:
    raise RuntimeError("Expected one canonical link")
source_iri = urljoin(str(response.url), canonical[0].get("href"))

# XPath provides the same attribute-name discovery query as [rdf-predicate].
rows: list[list[str]] = []
for element in document.xpath("//*[@rdf-predicate]"):
    if any(local_name(parent) == "template" for parent in element.iterancestors()):
        continue
    subject = urljoin(source_iri, element.get("rdf-subject"))
    predicate = urljoin(source_iri, element.get("rdf-predicate"))

    # The generated profile identifies any native attribute that carries the value.
    carriers = [
        rule
        for rule in CARRIER_RULES
        if rule[0] == local_name(element) and element.get(rule[1]) is not None
    ]
    if len(carriers) > 1:
        raise RuntimeError(f"Conflicting value carriers on <{local_name(element)}>")
    carrier = carriers[0] if carriers else None

    # IRI carrier values are references; resolve them against the semantic source.
    if carrier and carrier[2] == "iri":
        object_iri = urljoin(source_iri, element.get(carrier[1]))
        rows.append([subject, predicate, "I", object_iri, "", "", ""])
        continue

    # Literal carriers are exact strings. Other elements use normalized visible text.
    if carrier and carrier[2] == "literal":
        value = element.get(carrier[1])
    else:
        value = re.sub(r"[\t\n\f\r ]+", " ", "".join(element.itertext())).strip()

    # lang and dir apply only when they are directly present on this carrier.
    language = element.get("lang", "")
    direction = element.get("dir", "").lower()
    direction = direction if direction in {"ltr", "rtl"} else ""

    # An explicit datatype wins; direction otherwise selects rdf:dirLangString.
    datatype = RDF_DIR_LANG_STRING if direction else XSD_STRING
    if element.get("rdf-datatype") is not None:
        datatype = urljoin(source_iri, element.get("rdf-datatype"))
    rows.append([subject, predicate, "L", value, datatype, language, direction])

for row in sorted(rows, key=lambda item: item[1]):
    print("\t".join(row))
JavaFetch: HttpClientParser: jsoupView source
java/src/main/java/dev/ia2/example/Extract.javaGitHub
package dev.ia2.example;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;

public final class Extract {
  private static final String XSD_STRING = "http://www.w3.org/2001/XMLSchema#string";
  private static final String RDF_DIR_LANG_STRING =
      "http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString";

  private static String resolve(String base, String reference) {
    return URI.create(base).resolve(reference).toString();
  }

  public static void main(String[] args) throws Exception {
    // Fetch and parse the document as HTML.
    var client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build();
    var request = HttpRequest.newBuilder(URI.create(args[0])).GET().build();
    var response = client.send(request, HttpResponse.BodyHandlers.ofByteArray());
    if (response.statusCode() < 200 || response.statusCode() >= 300) {
      throw new IllegalStateException("HTTP " + response.statusCode());
    }
    var html = new String(response.body(), StandardCharsets.UTF_8);
    Document document = Jsoup.parse(html, response.uri().toString());

    // HTML/RDF resolves semantic references against one canonical source IRI.
    List<Element> canonical = document.select("head link[rel][href]").stream()
        .filter(link -> List.of(link.attr("rel").split("\\s+")).stream()
            .anyMatch("canonical"::equalsIgnoreCase))
        .toList();
    if (canonical.size() != 1) {
      throw new IllegalStateException("Expected one canonical link");
    }
    String sourceIri = resolve(response.uri().toString(), canonical.get(0).attr("href"));

    // A normal attribute selector finds every locally complete carrier.
    List<List<String>> rows = new ArrayList<>();
    for (Element element : document.select("[rdf-predicate]")) {
      boolean insideTemplate = element.parents().stream()
          .anyMatch(parent -> parent.normalName().equals("template"));
      if (insideTemplate) {
        continue;
      }
      String subject = resolve(sourceIri, element.attr("rdf-subject"));
      String predicate = resolve(sourceIri, element.attr("rdf-predicate"));

      // The generated profile identifies any native attribute that carries the value.
      List<CarrierRule> carriers = CarrierProfile.RULES.stream()
          .filter(rule -> rule.element().equals(element.normalName()))
          .filter(rule -> element.hasAttr(rule.attribute()))
          .toList();
      if (carriers.size() > 1) {
        throw new IllegalStateException("Conflicting value carriers on <"
            + element.normalName() + ">");
      }
      CarrierRule carrier = carriers.isEmpty() ? null : carriers.get(0);

      // IRI carrier values are references; resolve them against the semantic source.
      if (carrier != null && carrier.kind().equals("iri")) {
        String objectIri = resolve(sourceIri, element.attr(carrier.attribute()));
        rows.add(List.of(subject, predicate, "I", objectIri, "", "", ""));
        continue;
      }

      // Literal carriers are exact strings. Other elements use normalized visible text.
      String value = carrier != null && carrier.kind().equals("literal")
          ? element.attr(carrier.attribute())
          : element.text();

      // lang and dir apply only when directly present on this carrier.
      String language = element.attr("lang");
      String direction = element.attr("dir").toLowerCase();
      if (!direction.equals("ltr") && !direction.equals("rtl")) {
        direction = "";
      }

      // An explicit datatype wins; direction otherwise selects rdf:dirLangString.
      String datatype = direction.isEmpty() ? XSD_STRING : RDF_DIR_LANG_STRING;
      if (element.hasAttr("rdf-datatype")) {
        datatype = resolve(sourceIri, element.attr("rdf-datatype"));
      }
      rows.add(List.of(subject, predicate, "L", value, datatype, language, direction));
    }
    rows.sort(Comparator.comparing(row -> row.get(1)));
    rows.forEach(row -> System.out.println(String.join("\t", row)));
  }
}
KotlinFetch: Ktor ClientParser: jsoupView source
kotlin/src/main/kotlin/Main.ktGitHub
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import java.net.URI
import kotlinx.coroutines.runBlocking
import org.jsoup.Jsoup

private const val XSD_STRING = "http://www.w3.org/2001/XMLSchema#string"
private const val RDF_DIR_LANG_STRING = "http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString"
private fun resolve(base: String, reference: String) =
    URI.create(base).resolve(reference).toString()

fun main(args: Array<String>) = runBlocking {
    HttpClient(CIO) { followRedirects = true }.use { client ->
        // Fetch and parse the document as HTML.
        val response = client.get(args[0])
        val retrievalIri = response.call.request.url.toString()
        val document = Jsoup.parse(response.bodyAsText(), retrievalIri)

        // Resolve semantic references against the one canonical source IRI.
        val canonical = document.select("head link[rel][href]").filter {
            it.attr("rel").split(Regex("\\s+")).any { token ->
                token.equals("canonical", ignoreCase = true)
            }
        }
        require(canonical.size == 1) { "Expected one canonical link" }
        val sourceIri = resolve(retrievalIri, canonical.first()!!.attr("href"))

        // A normal attribute selector finds every locally complete carrier.
        val rows = document.select("[rdf-predicate]").asSequence()
            .filterNot { it.parents().any { parent -> parent.normalName() == "template" } }
            .map { element ->
                val subject = resolve(sourceIri, element.attr("rdf-subject"))
                val predicate = resolve(sourceIri, element.attr("rdf-predicate"))

                // The generated profile identifies any native attribute carrying the value.
                val valueCarriers = carrierRules.filter { rule ->
                    rule.element == element.normalName() && element.hasAttr(rule.attribute)
                }
                require(valueCarriers.size <= 1) {
                    "Conflicting value carriers on <${element.normalName()}>"
                }
                val carrier = valueCarriers.singleOrNull()

                // IRI carrier values are references; resolve them against the semantic source.
                if (carrier?.kind == "iri") {
                    val objectIri = resolve(sourceIri, element.attr(carrier.attribute))
                    listOf(subject, predicate, "I", objectIri, "", "", "")
                } else {
                    // Literal carriers are exact strings. Other elements use normalized text.
                    val value = carrier?.takeIf { it.kind == "literal" }
                        ?.let { element.attr(it.attribute) } ?: element.text()

                    // lang and dir apply only when directly present on this carrier.
                    val language = element.attr("lang")
                    val direction = element.attr("dir").lowercase()
                        .takeIf { it == "ltr" || it == "rtl" } ?: ""

                    // An explicit datatype wins; direction otherwise selects dirLangString.
                    val datatype = when {
                        element.hasAttr("rdf-datatype") ->
                            resolve(sourceIri, element.attr("rdf-datatype"))
                        direction.isNotEmpty() -> RDF_DIR_LANG_STRING
                        else -> XSD_STRING
                    }
                    listOf(subject, predicate, "L", value, datatype, language, direction)
                }
            }.sortedBy { it[1] }
        rows.forEach { println(it.joinToString("\t")) }
    }
}
ScalaFetch: sttp clientParser: jsoupView source
scala/Extract.scalaGitHub
//> using scala "3.7.1"
//> using dep "com.softwaremill.sttp.client3::core:3.11.0"
//> using dep "org.jsoup:jsoup:1.22.2"

import java.net.URI
import org.jsoup.Jsoup
import scala.jdk.CollectionConverters.*
import sttp.client3.*

@main def extract(url: String): Unit =
  // Fetch and parse the document as HTML.
  val backend = HttpURLConnectionBackend()
  val response = basicRequest.get(uri"$url").send(backend)
  val body = response.body.fold(error => throw RuntimeException(error), identity)
  val retrievalIri = url
  val document = Jsoup.parse(body, retrievalIri)

  // Resolve semantic references against the one canonical source IRI.
  val canonical = document.select("head link[rel][href]").asScala.filter { link =>
    link.attr("rel").split("\\s+").exists(_.equalsIgnoreCase("canonical"))
  }
  require(canonical.size == 1, "Expected one canonical link")
  def resolve(base: String, reference: String) = URI.create(base).resolve(reference).toString
  val sourceIri = resolve(retrievalIri, canonical.head.attr("href"))
  val xsdString = "http://www.w3.org/2001/XMLSchema#string"
  val rdfDirLangString = "http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString"

  // A normal attribute selector finds every locally complete carrier.
  val rows = document.select("[rdf-predicate]").asScala
    .filterNot(_.parents().asScala.exists(_.normalName() == "template"))
    .map { element =>
      val subject = resolve(sourceIri, element.attr("rdf-subject"))
      val predicate = resolve(sourceIri, element.attr("rdf-predicate"))

      // The generated profile identifies any native attribute that carries the value.
      val carriers = carrierRules.filter { rule =>
        rule.element == element.normalName() && element.hasAttr(rule.attribute)
      }
      require(carriers.size <= 1, s"Conflicting value carriers on <${element.normalName()}>")
      val carrier = carriers.headOption

      // IRI carrier values are references; resolve them against the semantic source.
      if carrier.exists(_.kind == "iri") then
        val objectIri = resolve(sourceIri, element.attr(carrier.get.attribute))
        Seq(subject, predicate, "I", objectIri, "", "", "")
      else
        // Literal carriers are exact strings. Other elements use normalized visible text.
        val value = carrier.filter(_.kind == "literal")
          .map(rule => element.attr(rule.attribute))
          .getOrElse(element.text())

        // lang and dir apply only when directly present on this carrier.
        val language = element.attr("lang")
        val rawDirection = element.attr("dir").toLowerCase
        val direction = if rawDirection == "ltr" || rawDirection == "rtl" then rawDirection else ""

        // An explicit datatype wins; direction otherwise selects rdf:dirLangString.
        val datatype =
          if element.hasAttr("rdf-datatype") then
            resolve(sourceIri, element.attr("rdf-datatype"))
          else if direction.nonEmpty then
            rdfDirLangString
          else
            xsdString
        Seq(subject, predicate, "L", value, datatype, language, direction)
    }.sortBy(_(1))
  rows.foreach(row => println(row.mkString("\t")))
  backend.close()
C# / .NETFetch: HttpClientParser: AngleSharpView source
csharp/Program.csGitHub
using System.Text.RegularExpressions;
using AngleSharp.Dom;
using AngleSharp.Html.Parser;

const string XsdString = "http://www.w3.org/2001/XMLSchema#string";
const string RdfDirLangString = "http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString";

static string Resolve(string baseIri, string reference) =>
    new Uri(new Uri(baseIri), reference).AbsoluteUri;
static bool InsideTemplate(IElement element)
{
    for (var parent = element.ParentElement; parent is not null; parent = parent.ParentElement)
    {
        if (parent.LocalName == "template")
        {
            return true;
        }
    }
    return false;
}

// Fetch and parse the document as HTML.
using var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = true });
using var response = await client.GetAsync(args[0]);
response.EnsureSuccessStatusCode();
var retrievalIri = response.RequestMessage!.RequestUri!.AbsoluteUri;
var html = await response.Content.ReadAsStringAsync();
var document = await new HtmlParser().ParseDocumentAsync(html);

// Resolve semantic references against the one canonical source IRI.
var canonical = document.Head!.QuerySelectorAll("link[rel][href]")
    .Where(link => link.GetAttribute("rel")!
        .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
        .Any(token => token.Equals("canonical", StringComparison.OrdinalIgnoreCase)))
    .ToList();
if (canonical.Count != 1)
{
    throw new InvalidOperationException("Expected one canonical link");
}
var sourceIri = Resolve(retrievalIri, canonical[0].GetAttribute("href")!);

// A normal attribute selector finds every locally complete carrier.
var rows = new List<string[]>();
var carriers = document.QuerySelectorAll("[rdf-predicate]")
    .Where(element => !InsideTemplate(element));
foreach (var element in carriers)
{
    var subject = Resolve(sourceIri, element.GetAttribute("rdf-subject")!);
    var predicate = Resolve(sourceIri, element.GetAttribute("rdf-predicate")!);

    // The generated profile identifies any native attribute that carries the value.
    var valueCarriers = CarrierProfile.Rules
        .Where(rule => rule.Element == element.LocalName && element.HasAttribute(rule.Attribute))
        .ToList();
    if (valueCarriers.Count > 1)
    {
        throw new InvalidOperationException(Source is added from the example file during the site build.quot;Conflicting value carriers on <{element.LocalName}>");
    }
    CarrierRule? carrier = valueCarriers.Count == 0 ? null : valueCarriers[0];

    // IRI carrier values are references; resolve them against the semantic source.
    if (carrier is { Kind: "iri" } iriCarrier)
    {
        var objectIri = Resolve(sourceIri, element.GetAttribute(iriCarrier.Attribute)!);
        rows.Add([subject, predicate, "I", objectIri, "", "", ""]);
        continue;
    }

    // Literal carriers are exact strings. Other elements use normalized visible text.
    var value = carrier is { Kind: "literal" } literalCarrier
        ? element.GetAttribute(literalCarrier.Attribute)!
        : Regex.Replace(element.TextContent, "[\\t\\n\\f\\r ]+", " ").Trim();

    // lang and dir apply only when directly present on this carrier.
    var language = element.GetAttribute("lang") ?? "";
    var rawDirection = (element.GetAttribute("dir") ?? "").ToLowerInvariant();
    var direction = rawDirection is "ltr" or "rtl" ? rawDirection : "";

    // An explicit datatype wins; direction otherwise selects rdf:dirLangString.
    var datatype = direction.Length > 0 ? RdfDirLangString : XsdString;
    if (element.HasAttribute("rdf-datatype"))
    {
        datatype = Resolve(sourceIri, element.GetAttribute("rdf-datatype")!);
    }
    rows.Add([subject, predicate, "L", value, datatype, language, direction]);
}
foreach (var row in rows.OrderBy(row => row[1]))
{
    Console.WriteLine(string.Join('\t', row));
}
GoFetch: net/httpParser: x/net/html + goqueryView source
go/main.goGitHub
package main

import (
	"fmt"
	"net/http"
	"net/url"
	"os"
	"sort"
	"strings"

	"github.com/PuerkitoBio/goquery"
	"golang.org/x/net/html/charset"
)

const xsdString = "http://www.w3.org/2001/XMLSchema#string"
const rdfDirLangString = "http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString"

func resolve(base, reference string) string {
	b, err := url.Parse(base)
	if err != nil {
		panic(err)
	}
	r, err := url.Parse(reference)
	if err != nil {
		panic(err)
	}
	return b.ResolveReference(r).String()
}

func main() {
	// Fetch and parse the document as HTML.
	response, err := http.Get(os.Args[1])
	if err != nil {
		panic(err)
	}
	defer response.Body.Close()
	if response.StatusCode < 200 || response.StatusCode >= 300 {
		panic(response.Status)
	}
	reader, err := charset.NewReader(response.Body, response.Header.Get("Content-Type"))
	if err != nil {
		panic(err)
	}
	document, err := goquery.NewDocumentFromReader(reader)
	if err != nil {
		panic(err)
	}

	// Resolve semantic references against the one canonical source IRI.
	var canonical []string
	document.Find("head link[rel][href]").Each(func(_ int, selection *goquery.Selection) {
		rel, _ := selection.Attr("rel")
		for _, token := range strings.Fields(strings.ToLower(rel)) {
			if token == "canonical" {
				href, _ := selection.Attr("href")
				canonical = append(canonical, href)
			}
		}
	})
	if len(canonical) != 1 {
		panic("expected one canonical link")
	}
	sourceIRI := resolve(response.Request.URL.String(), canonical[0])

	// A normal attribute selector finds every locally complete carrier.
	rows := make([][]string, 0, 4)
	document.Find("[rdf-predicate]").Each(func(_ int, element *goquery.Selection) {
		if element.ParentsFiltered("template").Length() != 0 {
			return
		}
		subjectRef, _ := element.Attr("rdf-subject")
		predicateRef, _ := element.Attr("rdf-predicate")
		subject := resolve(sourceIRI, subjectRef)
		predicate := resolve(sourceIRI, predicateRef)

		// The generated profile identifies any native attribute carrying the value.
		var carriers []carrierRule
		for _, rule := range carrierRules {
			_, present := element.Attr(rule.attribute)
			if rule.element == goquery.NodeName(element) && present {
				carriers = append(carriers, rule)
			}
		}
		if len(carriers) > 1 {
			panic("conflicting value carriers on <" + goquery.NodeName(element) + ">")
		}
		var carrier *carrierRule
		if len(carriers) == 1 {
			carrier = &carriers[0]
		}

		// IRI carrier values are references; resolve them against the semantic source.
		if carrier != nil && carrier.kind == "iri" {
			value, _ := element.Attr(carrier.attribute)
			rows = append(rows, []string{subject, predicate, "I", resolve(sourceIRI, value), "", "", ""})
			return
		}

		// Literal carriers are exact strings. Other elements use normalized text.
		value := element.Text()
		if carrier != nil && carrier.kind == "literal" {
			value, _ = element.Attr(carrier.attribute)
		} else {
			value = strings.Join(strings.Fields(value), " ")
		}

		// lang and dir apply only when directly present on this carrier.
		language, _ := element.Attr("lang")
		direction, _ := element.Attr("dir")
		direction = strings.ToLower(direction)
		if direction != "ltr" && direction != "rtl" {
			direction = ""
		}

		// An explicit datatype wins; direction otherwise selects rdf:dirLangString.
		datatype := xsdString
		if declared, ok := element.Attr("rdf-datatype"); ok {
			datatype = resolve(sourceIRI, declared)
		} else if direction != "" {
			datatype = rdfDirLangString
		}
		rows = append(rows, []string{subject, predicate, "L", value, datatype, language, direction})
	})
	sort.Slice(rows, func(i, j int) bool { return rows[i][1] < rows[j][1] })
	for _, row := range rows {
		fmt.Println(strings.Join(row, "\t"))
	}
}
RustFetch: reqwestParser: scraper + html5everView source
rust/src/main.rsGitHub
mod carrier_profile;

use carrier_profile::CARRIER_RULES;
use scraper::{ElementRef, Html, Selector};
use std::error::Error;
use url::Url;

const XSD_STRING: &str = "http://www.w3.org/2001/XMLSchema#string";
const RDF_DIR_LANG_STRING: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString";

fn normalize(value: &str) -> String {
    value.split_whitespace().collect::<Vec<_>>().join(" ")
}

fn inside_template(element: &ElementRef<'_>) -> bool {
    element
        .ancestors()
        .skip(1)
        .filter_map(ElementRef::wrap)
        .any(|ancestor| ancestor.value().name() == "template")
}

fn main() -> Result<(), Box<dyn Error>> {
    // Fetch and parse the document as HTML.
    let response = reqwest::blocking::get(std::env::args().nth(1).ok_or("missing URL")?)?
        .error_for_status()?;
    let retrieval_iri = response.url().clone();
    let document = Html::parse_document(&response.text()?);

    // Resolve semantic references against the one canonical source IRI.
    let links = Selector::parse("head link[rel][href]").unwrap();
    let canonical: Vec<_> = document
        .select(&links)
        .filter(|element| {
            element
                .value()
                .attr("rel")
                .unwrap_or("")
                .split_ascii_whitespace()
                .any(|token| token.eq_ignore_ascii_case("canonical"))
        })
        .collect();
    if canonical.len() != 1 {
        return Err("expected one canonical link".into());
    }
    let source_iri = retrieval_iri.join(canonical[0].value().attr("href").unwrap())?;

    // A normal attribute selector finds every locally complete carrier.
    let statements = Selector::parse("[rdf-predicate]").unwrap();
    let mut rows: Vec<Vec<String>> = Vec::new();
    for element in document
        .select(&statements)
        .filter(|element| !inside_template(element))
    {
        let subject = source_iri
            .join(element.value().attr("rdf-subject").unwrap())?
            .to_string();
        let predicate = source_iri
            .join(element.value().attr("rdf-predicate").unwrap())?
            .to_string();

        // The generated profile identifies any native attribute carrying the value.
        let carriers: Vec<_> = CARRIER_RULES
            .iter()
            .filter(|rule| {
                rule.element == element.value().name()
                    && element.value().attr(rule.attribute).is_some()
            })
            .collect();
        if carriers.len() > 1 {
            return Err(
                format!("conflicting value carriers on <{}>", element.value().name()).into(),
            );
        }
        let carrier = carriers.first().copied();

        // IRI carrier values are references; resolve them against the semantic source.
        if let Some(rule) = carrier.filter(|rule| rule.kind == "iri") {
            let object = source_iri
                .join(element.value().attr(rule.attribute).unwrap())?
                .to_string();
            rows.push(vec![
                subject,
                predicate,
                "I".into(),
                object,
                "".into(),
                "".into(),
                "".into(),
            ]);
            continue;
        }

        // Literal carriers are exact strings. Other elements use normalized visible text.
        let value = if let Some(rule) = carrier.filter(|rule| rule.kind == "literal") {
            element.value().attr(rule.attribute).unwrap().to_owned()
        } else {
            normalize(&element.text().collect::<String>())
        };

        // lang and dir apply only when directly present on this carrier.
        let language = element.value().attr("lang").unwrap_or("").to_owned();
        let direction = match element
            .value()
            .attr("dir")
            .unwrap_or("")
            .to_ascii_lowercase()
            .as_str()
        {
            "ltr" => "ltr",
            "rtl" => "rtl",
            _ => "",
        }
        .to_owned();

        // An explicit datatype wins; direction otherwise selects rdf:dirLangString.
        let datatype = match element.value().attr("rdf-datatype") {
            Some(value) => source_iri.join(value)?.to_string(),
            None if !direction.is_empty() => RDF_DIR_LANG_STRING.into(),
            None => XSD_STRING.into(),
        };
        rows.push(vec![
            subject,
            predicate,
            "L".into(),
            value,
            datatype,
            language,
            direction,
        ]);
    }
    rows.sort_by(|left, right| left[1].cmp(&right[1]));
    for row in rows {
        println!("{}", row.join("\t"));
    }
    let _: Url = source_iri;
    Ok(())
}
PHP 8.4+Fetch: Symfony HttpClientParser: Dom\HTMLDocumentView source
php/extract.phpGitHub
<?php

require __DIR__ . '/vendor/autoload.php';
$carrierRules = require __DIR__ . '/carrier-profile.php';

use Symfony\Component\HttpClient\HttpClient;

const XSD_STRING = 'http://www.w3.org/2001/XMLSchema#string';
const RDF_DIR_LANG_STRING = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString';

function resolve_iri(string $base, string $reference): string {
    if (preg_match('/^[A-Za-z][A-Za-z0-9+.-]*:/', $reference)) {
        return $reference;
    }
    $parts = parse_url($base);
    $port = isset($parts['port']) ? ':' . $parts['port'] : '';
    $origin = $parts['scheme'] . '://' . $parts['host'] . $port;
    if (str_starts_with($reference, '#')) {
        return preg_replace('/#.*$/', '', $base) . $reference;
    }
    if (str_starts_with($reference, '/')) {
        return $origin . $reference;
    }
    $path = $parts['path'] ?? '/';
    return $origin . preg_replace('~/[^/]*$~', '/', $path) . $reference;
}

// Fetch and parse the document with the HTML5 DOM parser in PHP 8.4+.
$response = HttpClient::create(['max_redirects' => 5])->request('GET', $argv[1]);
$html = $response->getContent();
$retrievalIri = $response->getInfo('url');
$document = Dom\HTMLDocument::createFromString($html, LIBXML_NOERROR);

// Resolve semantic references against the one canonical source IRI.
$canonical = [];
foreach ($document->querySelectorAll('head link[rel][href]') as $link) {
    $relTokens = preg_split('/\s+/', strtolower($link->getAttribute('rel')));
    if (in_array('canonical', $relTokens, true)) {
        $canonical[] = $link;
    }
}
if (count($canonical) !== 1) {
    throw new RuntimeException('Expected one canonical link');
}
$sourceIri = resolve_iri($retrievalIri, $canonical[0]->getAttribute('href'));

// A normal attribute selector finds every locally complete carrier.
$rows = [];
foreach ($document->querySelectorAll('[rdf-predicate]') as $element) {
    $insideTemplate = false;
    for ($parent = $element->parentElement; $parent !== null; $parent = $parent->parentElement) {
        if ($parent->localName === 'template') {
            $insideTemplate = true;
            break;
        }
    }
    if ($insideTemplate) {
        continue;
    }
    $subject = resolve_iri($sourceIri, $element->getAttribute('rdf-subject'));
    $predicate = resolve_iri($sourceIri, $element->getAttribute('rdf-predicate'));

    // The generated profile identifies any native attribute carrying the value.
    $valueCarriers = array_values(array_filter(
        $carrierRules,
        fn($rule) => $rule[0] === $element->localName && $element->hasAttribute($rule[1])
    ));
    if (count($valueCarriers) > 1) {
        throw new RuntimeException("Conflicting value carriers on <{$element->localName}>");
    }
    $carrier = $valueCarriers[0] ?? null;

    // IRI carrier values are references; resolve them against the semantic source.
    if ($carrier !== null && $carrier[2] === 'iri') {
        $objectIri = resolve_iri($sourceIri, $element->getAttribute($carrier[1]));
        $rows[] = [$subject, $predicate, 'I', $objectIri, '', '', ''];
        continue;
    }

    // Literal carriers are exact strings. Other elements use normalized visible text.
    if ($carrier !== null && $carrier[2] === 'literal') {
        $value = $element->getAttribute($carrier[1]);
    } else {
        $value = trim(preg_replace('/[\t\n\f\r ]+/', ' ', $element->textContent));
    }

    // lang and dir apply only when directly present on this carrier.
    $language = $element->getAttribute('lang') ?? '';
    $rawDirection = strtolower($element->getAttribute('dir') ?? '');
    $direction = in_array($rawDirection, ['ltr', 'rtl'], true) ? $rawDirection : '';

    // An explicit datatype wins; direction otherwise selects rdf:dirLangString.
    $datatype = $direction !== '' ? RDF_DIR_LANG_STRING : XSD_STRING;
    if ($element->hasAttribute('rdf-datatype')) {
        $datatype = resolve_iri($sourceIri, $element->getAttribute('rdf-datatype'));
    }
    $rows[] = [$subject, $predicate, 'L', $value, $datatype, $language, $direction];
}
usort($rows, fn($left, $right) => $left[1] <=> $right[1]);
foreach ($rows as $row) {
    echo implode("\t", $row) . "\n";
}
RubyFetch: FaradayParser: Nokogiri HTML5View source
ruby/extract.rbGitHub
# frozen_string_literal: true

require "faraday"
require "nokogiri"
require "uri"
require_relative "carrier_profile"

XSD_STRING = "http://www.w3.org/2001/XMLSchema#string"
RDF_DIR_LANG_STRING = "http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString"

# Fetch and parse the document as HTML5.
response = Faraday.get(ARGV.fetch(0))
raise "HTTP #{response.status}" unless response.success?
retrieval_iri = response.env.url.to_s
document = Nokogiri::HTML5(response.body, url: retrieval_iri)

# Resolve semantic references against the one canonical source IRI.
canonical = document.css("head link[rel][href]").select do |link|
  link["rel"].split.any? { |token| token.casecmp?("canonical") }
end
raise "Expected one canonical link" unless canonical.one?
source_iri = URI.join(retrieval_iri, canonical.first["href"]).to_s

# A normal attribute selector finds every locally complete carrier.
carriers = document.css("[rdf-predicate]").reject do |element|
  element.ancestors("template").any?
end

rows = carriers.map do |element|
  subject = URI.join(source_iri, element["rdf-subject"]).to_s
  predicate = URI.join(source_iri, element["rdf-predicate"]).to_s

  # The generated profile identifies any native attribute carrying the value.
  value_carriers = CARRIER_RULES.select do |rule|
    rule[0] == element.name && element.key?(rule[1])
  end
  raise "Conflicting value carriers on <#{element.name}>" if value_carriers.length > 1
  carrier = value_carriers.first

  # IRI carrier values are references; resolve them against the semantic source.
  if carrier && carrier[2] == "iri"
    object_iri = URI.join(source_iri, element[carrier[1]]).to_s
    [subject, predicate, "I", object_iri, "", "", ""]
  else
    # Literal carriers are exact strings. Other elements use normalized visible text.
    value = if carrier && carrier[2] == "literal"
              element[carrier[1]]
            else
              element.text.gsub(/[\t\n\f\r ]+/, " ").strip
            end

    # lang and dir apply only when directly present on this carrier.
    language = element["lang"] || ""
    raw_direction = (element["dir"] || "").downcase
    direction = %w[ltr rtl].include?(raw_direction) ? raw_direction : ""

    # An explicit datatype wins; direction otherwise selects rdf:dirLangString.
    datatype = direction.empty? ? XSD_STRING : RDF_DIR_LANG_STRING
    if element.key?("rdf-datatype")
      datatype = URI.join(source_iri, element["rdf-datatype"]).to_s
    end
    [subject, predicate, "L", value, datatype, language, direction]
  end
end
rows.sort_by { |row| row[1] }.each do |row|
  puts row.join("\t")
end
SwiftFetch: URLSessionParser: SwiftSoupView source
swift/Sources/HtmlRdfExample/main.swiftGitHub
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
import SwiftSoup

let xsdString = "http://www.w3.org/2001/XMLSchema#string"
let rdfDirLangString = "http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString"

func resolve(_ base: String, _ reference: String) throws -> String {
    guard let baseUrl = URL(string: base),
          let result = URL(string: reference, relativeTo: baseUrl)?.absoluteURL else {
        throw URLError(.badURL)
    }
    return result.absoluteString
}

struct HtmlRdfExample {
    static func run() async throws {
        // Fetch and parse the document as HTML.
        let inputURL = URL(string: CommandLine.arguments[1])!
        let (data, response) = try await URLSession.shared.data(from: inputURL)
        guard let http = response as? HTTPURLResponse,
              (200..<300).contains(http.statusCode) else {
            throw URLError(.badServerResponse)
        }
        let retrievalIri = response.url!.absoluteString
        let document = try SwiftSoup.parseHTML(data, retrievalIri)

        // Resolve semantic references against the one canonical source IRI.
        let canonical = try document.select("head link[rel][href]").array().filter { link in
            try link.attr("rel")
                .split(whereSeparator: { $0.isWhitespace })
                .contains { $0.lowercased() == "canonical" }
        }
        guard canonical.count == 1 else {
            throw NSError(
                domain: "HtmlRdfExample",
                code: 1,
                userInfo: [NSLocalizedDescriptionKey: "Expected one canonical link"]
            )
        }
        let sourceIri = try resolve(retrievalIri, canonical[0].attr("href"))

        // A normal attribute selector finds every locally complete carrier.
        var rows: [[String]] = []
        for element in try document.select("[rdf-predicate]").array() {
            let insideTemplate = element.parents().array().contains { $0.tagName() == "template" }
            if insideTemplate { continue }
            let subject = try resolve(sourceIri, element.attr("rdf-subject"))
            let predicate = try resolve(sourceIri, element.attr("rdf-predicate"))

            // The generated profile identifies any native attribute carrying the value.
            let valueCarriers = CarrierProfile.rules.filter { rule in
                rule.element == element.tagName() && element.hasAttr(rule.attribute)
            }
            guard valueCarriers.count <= 1 else {
                throw NSError(
                    domain: "HtmlRdfExample",
                    code: 2,
                    userInfo: [NSLocalizedDescriptionKey:
                        "Conflicting value carriers on <\(element.tagName())>"]
                )
            }
            let carrier = valueCarriers.first

            // IRI carrier values are references; resolve them against the semantic source.
            if let carrier, carrier.kind == "iri" {
                let objectIri = try resolve(sourceIri, element.attr(carrier.attribute))
                rows.append([subject, predicate, "I", objectIri, "", "", ""])
                continue
            }

            // Literal carriers are exact strings. Other elements use normalized text.
            let value: String
            if let carrier, carrier.kind == "literal" {
                value = try element.attr(carrier.attribute)
            } else {
                value = try element.text()
            }

            // lang and dir apply only when directly present on this carrier.
            let language = try element.attr("lang")
            let rawDirection = try element.attr("dir").lowercased()
            let direction = ["ltr", "rtl"].contains(rawDirection) ? rawDirection : ""

            // An explicit datatype wins; direction otherwise selects rdf:dirLangString.
            let datatype: String
            if element.hasAttr("rdf-datatype") {
                datatype = try resolve(sourceIri, element.attr("rdf-datatype"))
            } else {
                datatype = direction.isEmpty ? xsdString : rdfDirLangString
            }
            rows.append([subject, predicate, "L", value, datatype, language, direction])
        }
        for row in rows.sorted(by: { $0[1] < $1[1] }) {
            print(row.joined(separator: "\t"))
        }
    }
}

try await HtmlRdfExample.run()
DartFetch: package:httpParser: package:htmlView source
dart/bin/extract.dartGitHub
import 'package:html/dom.dart';
import 'package:html/parser.dart' show parse;
import 'package:html_rdf_library_example/carrier_profile.dart';
import 'package:http/http.dart' as http;

const xsdString = 'http://www.w3.org/2001/XMLSchema#string';
const rdfDirLangString = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString';

bool insideTemplate(Element element) {
  for (var parent = element.parent; parent != null; parent = parent.parent) {
    if (parent.localName == 'template') {
      return true;
    }
  }
  return false;
}

Future<void> main(List<String> args) async {
  // Fetch and parse the document as HTML.
  final response = await http.get(Uri.parse(args[0]));
  if (response.statusCode < 200 || response.statusCode >= 300) {
    throw StateError('HTTP ${response.statusCode}');
  }
  final retrievalIri = response.request!.url;
  final document = parse(response.bodyBytes, sourceUrl: retrievalIri.toString());

  // Resolve semantic references against the one canonical source IRI.
  final canonical = document
      .querySelectorAll('head link[rel][href]')
      .where((link) => link.attributes['rel']!
          .split(RegExp(r'\s+'))
          .any((token) => token.toLowerCase() == 'canonical'))
      .toList();
  if (canonical.length != 1) {
    throw StateError('Expected one canonical link');
  }
  final sourceIri = retrievalIri.resolve(canonical.single.attributes['href']!);

  // A normal attribute selector finds every locally complete carrier.
  final rows = <List<String>>[];
  final carriers = document
      .querySelectorAll('[rdf-predicate]')
      .where((element) => !insideTemplate(element));
  for (final element in carriers) {
    final subject = sourceIri.resolve(element.attributes['rdf-subject']!).toString();
    final predicate = sourceIri.resolve(element.attributes['rdf-predicate']!).toString();

    // The generated profile identifies any native attribute carrying the value.
    final valueCarriers = carrierRules
        .where((rule) => rule.element == element.localName &&
            element.attributes.containsKey(rule.attribute))
        .toList();
    if (valueCarriers.length > 1) {
      throw StateError('Conflicting value carriers on <${element.localName}>');
    }
    final carrier = valueCarriers.isEmpty ? null : valueCarriers.first;

    // IRI carrier values are references; resolve them against the semantic source.
    if (carrier?.kind == 'iri') {
      final objectIri = sourceIri
          .resolve(element.attributes[carrier!.attribute]!)
          .toString();
      rows.add([subject, predicate, 'I', objectIri, '', '', '']);
      continue;
    }

    // Literal carriers are exact strings. Other elements use normalized visible text.
    final value = carrier?.kind == 'literal'
        ? element.attributes[carrier!.attribute]!
        : element.text.replaceAll(RegExp(r'[\t\n\f\r ]+'), ' ').trim();

    // lang and dir apply only when directly present on this carrier.
    final language = element.attributes['lang'] ?? '';
    final rawDirection = (element.attributes['dir'] ?? '').toLowerCase();
    final direction = rawDirection == 'ltr' || rawDirection == 'rtl' ? rawDirection : '';

    // An explicit datatype wins; direction otherwise selects rdf:dirLangString.
    final datatype = element.attributes.containsKey('rdf-datatype')
      ? sourceIri.resolve(element.attributes['rdf-datatype']!).toString()
      : direction.isNotEmpty ? rdfDirLangString : xsdString;
    rows.add([subject, predicate, 'L', value, datatype, language, direction]);
  }
  rows.sort((left, right) => left[1].compareTo(right[1]));
  for (final row in rows) {
    print(row.join('\t'));
  }
}
CFetch: libcurlParser: LexborView source
c/extract.cGitHub
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <curl/curl.h>
#include <lexbor/html/html.h>
#include "carrier_profile.h"

#define FIELD 512
#define XSD_STRING "http://www.w3.org/2001/XMLSchema#string"
#define RDF_DIR_LANG_STRING "http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString"

typedef struct {
    char *data;
    size_t length;
} buffer_t;

typedef struct {
    char field[7][FIELD];
} row_t;

typedef struct {
    const char *source;
    row_t rows[8];
    size_t length;
} extraction_t;

static size_t receive(char *data, size_t size, size_t count, void *ctx) {
    buffer_t *buffer = ctx;
    size_t length = size * count;
    char *next = realloc(buffer->data, buffer->length + length + 1);
    if (next == NULL)
        return 0;
    buffer->data = next;
    memcpy(buffer->data + buffer->length, data, length);
    buffer->length += length;
    buffer->data[buffer->length] = '\0';
    return length;
}

static int attribute(lxb_dom_element_t *element, const char *name, char *output) {
    size_t length = 0;
    const lxb_char_t *value = lxb_dom_element_get_attribute(
        element, (const lxb_char_t *)name, strlen(name), &length);
    if (value == NULL) {
        output[0] = '\0';
        return 0;
    }
    if (length >= FIELD)
        length = FIELD - 1;
    memcpy(output, value, length);
    output[length] = '\0';
    return 1;
}

static int tag_is(lxb_dom_element_t *element, const char *expected) {
    size_t length = 0;
    const lxb_char_t *name = lxb_dom_element_local_name(element, &length);
    return strlen(expected) == length && memcmp(name, expected, length) == 0;
}

static void resolve_iri(const char *base, const char *reference, char *output) {
    const char *colon = strchr(reference, ':');
    if (colon != NULL && colon != reference) {
        snprintf(output, FIELD, "%s", reference);
        return;
    }
    if (reference[0] == '#') {
        size_t length = strcspn(base, "#");
        snprintf(output, FIELD, "%.*s%s", (int)length, base, reference);
        return;
    }
    const char *scheme = strstr(base, "://");
    const char *path = scheme == NULL ? base : strchr(scheme + 3, '/');
    size_t origin = path == NULL ? strlen(base) : (size_t)(path - base);
    if (reference[0] == '/') {
        snprintf(output, FIELD, "%.*s%s", (int)origin, base, reference);
        return;
    }
    size_t directory = strlen(base);
    while (directory > origin && base[directory - 1] != '/')
        directory--;
    snprintf(output, FIELD, "%.*s%s", (int)directory, base, reference);
}

static void normalize(const lxb_char_t *input, size_t length, char *output) {
    size_t write = 0;
    int pending_space = 0;
    for (size_t read = 0; read < length && write + 1 < FIELD; read++) {
        unsigned char value = input[read];
        if (value == '\t' || value == '\n' || value == '\f' || value == '\r' ||
            value == ' ') {
            if (write > 0)
                pending_space = 1;
            continue;
        }
        if (pending_space && write + 1 < FIELD)
            output[write++] = ' ';
        pending_space = 0;
        output[write++] = (char)value;
    }
    output[write] = '\0';
}

static void find_canonical(lxb_dom_node_t *node, char *canonical, size_t *count) {
    if (node->type == LXB_DOM_NODE_TYPE_ELEMENT) {
        lxb_dom_element_t *element = lxb_dom_interface_element(node);
        char rel[FIELD], href[FIELD];
        if (tag_is(element, "link") && attribute(element, "rel", rel) &&
            attribute(element, "href", href) && strcasecmp(rel, "canonical") == 0) {
            snprintf(canonical, FIELD, "%s", href);
            (*count)++;
        }
    }
    for (lxb_dom_node_t *child = node->first_child; child != NULL; child = child->next)
        find_canonical(child, canonical, count);
}

static void collect(lxb_dom_node_t *node, extraction_t *ctx) {
    int template = 0;
    if (node->type == LXB_DOM_NODE_TYPE_ELEMENT) {
        lxb_dom_element_t *element = lxb_dom_interface_element(node);
        template = tag_is(element, "template");
        char predicate_ref[FIELD];
        if (attribute(element, "rdf-predicate", predicate_ref)) {
            row_t *row = &ctx->rows[ctx->length++];
            char subject_ref[FIELD], carrier_value[FIELD], datatype_ref[FIELD], language[FIELD],
                direction[FIELD];
            attribute(element, "rdf-subject", subject_ref);
            resolve_iri(ctx->source, subject_ref, row->field[0]);
            resolve_iri(ctx->source, predicate_ref, row->field[1]);

            /* The generated profile identifies a native attribute carrying the value. */
            const carrier_rule_t *carrier = NULL;
            for (size_t i = 0; i < CARRIER_RULE_COUNT; i++) {
                if (tag_is(element, CARRIER_RULES[i].element) &&
                    attribute(element, CARRIER_RULES[i].attribute, carrier_value)) {
                    if (carrier != NULL)
                        abort();
                    carrier = &CARRIER_RULES[i];
                }
            }

            /* IRI carrier values are references resolved against the semantic source. */
            if (carrier != NULL && strcmp(carrier->kind, "iri") == 0) {
                strcpy(row->field[2], "I");
                resolve_iri(ctx->source, carrier_value, row->field[3]);
                row->field[4][0] = row->field[5][0] = row->field[6][0] = '\0';
            } else {
                strcpy(row->field[2], "L");

                /* Literal carriers are exact; other elements use normalized text. */
                if (carrier != NULL && strcmp(carrier->kind, "literal") == 0) {
                    strcpy(row->field[3], carrier_value);
                } else {
                    size_t length = 0;
                    lxb_char_t *text = lxb_dom_node_text_content(node, &length);
                    normalize(text, length, row->field[3]);
                }

                /* lang and dir apply only when directly present on this carrier. */
                attribute(element, "lang", language);
                strcpy(row->field[5], language);
                attribute(element, "dir", direction);
                for (char *p = direction; *p; p++)
                    *p = (char)tolower((unsigned char)*p);
                if (strcmp(direction, "ltr") != 0 && strcmp(direction, "rtl") != 0)
                    direction[0] = '\0';
                strcpy(row->field[6], direction);

                /* Explicit datatype wins; direction otherwise means dirLangString. */
                if (attribute(element, "rdf-datatype", datatype_ref))
                    resolve_iri(ctx->source, datatype_ref, row->field[4]);
                else
                    strcpy(row->field[4], direction[0] ? RDF_DIR_LANG_STRING : XSD_STRING);
            }
        }
    }
    if (!template)
        for (lxb_dom_node_t *child = node->first_child; child != NULL; child = child->next)
            collect(child, ctx);
}

static int compare_rows(const void *left, const void *right) {
    return strcmp(((const row_t *)left)->field[1], ((const row_t *)right)->field[1]);
}

int main(int argc, char **argv) {
    if (argc != 2)
        return EXIT_FAILURE;

    /* Fetch and parse the document as HTML. */
    CURL *curl = curl_easy_init();
    buffer_t html = {0};
    curl_easy_setopt(curl, CURLOPT_URL, argv[1]);
    curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
    curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, receive);
    curl_easy_setopt(curl, CURLOPT_WRITEDATA, &html);
    if (curl_easy_perform(curl) != CURLE_OK)
        return EXIT_FAILURE;
    char *retrieval = NULL;
    curl_easy_getinfo(curl, CURLINFO_EFFECTIVE_URL, &retrieval);
    lxb_html_document_t *document = lxb_html_document_create();
    if (document == NULL || lxb_html_document_parse(document, (lxb_char_t *)html.data,
                                                    html.length) != LXB_STATUS_OK)
        return EXIT_FAILURE;

    /* Resolve semantic references against the one canonical source IRI. */
    char canonical[FIELD];
    size_t canonical_count = 0;
    find_canonical(lxb_dom_interface_node(document), canonical, &canonical_count);
    if (canonical_count != 1)
        return EXIT_FAILURE;
    char source[FIELD];
    resolve_iri(retrieval, canonical, source);

    /* Walk the tree to find every locally complete statement carrier. */
    extraction_t extraction = {.source = source, .length = 0};
    collect(lxb_dom_interface_node(document), &extraction);
    qsort(extraction.rows, extraction.length, sizeof(row_t), compare_rows);
    for (size_t i = 0; i < extraction.length; i++) {
        for (size_t field = 0; field < 7; field++)
            printf("%s%c", extraction.rows[i].field[field], field == 6 ? '\n' : '\t');
    }
    lxb_html_document_destroy(document);
    curl_easy_cleanup(curl);
    free(html.data);
    return EXIT_SUCCESS;
}
C++Fetch: libcurlParser: LexborView source
cpp/extract.cppGitHub
#include <algorithm>
#include <cctype>
#include <cstring>
#include <iostream>
#include <stdexcept>
#include <string>
#include <string_view>
#include <vector>
#include <curl/curl.h>
#include <lexbor/html/html.h>
#include "carrier_profile.hpp"

using Row = std::vector<std::string>;
constexpr auto XSD_STRING = "http://www.w3.org/2001/XMLSchema#string";
constexpr auto RDF_DIR_LANG_STRING =
    "http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString";

static size_t receive(char *data, size_t size, size_t count, void *ctx) {
    static_cast<std::string *>(ctx)->append(data, size * count);
    return size * count;
}

static std::string attr(lxb_dom_element_t *element, const char *name) {
    size_t length = 0;
    auto value = lxb_dom_element_get_attribute(
        element, reinterpret_cast<const lxb_char_t *>(name), std::strlen(name), &length);
    return value == nullptr ? std::string()
                            : std::string(reinterpret_cast<const char *>(value), length);
}

static bool has_attr(lxb_dom_element_t *element, const char *name) {
    return lxb_dom_element_has_attribute(
        element, reinterpret_cast<const lxb_char_t *>(name), std::strlen(name));
}

static bool tag_is(lxb_dom_element_t *element, std::string_view expected) {
    size_t length = 0;
    auto name = lxb_dom_element_local_name(element, &length);
    return expected == std::string_view(reinterpret_cast<const char *>(name), length);
}

static std::string resolve(std::string base, const std::string &reference) {
    auto colon = reference.find(':');
    if (colon != std::string::npos && colon != 0)
        return reference;
    if (reference.starts_with('#')) {
        auto fragment = base.find('#');
        if (fragment != std::string::npos)
            base.resize(fragment);
        return base + reference;
    }
    auto scheme = base.find("://");
    auto slash = base.find('/', scheme == std::string::npos ? 0 : scheme + 3);
    auto origin = slash == std::string::npos ? base.size() : slash;
    if (reference.starts_with('/'))
        return base.substr(0, origin) + reference;
    return base.substr(0, base.rfind('/') + 1) + reference;
}

static std::string normalize(const lxb_char_t *value, size_t length) {
    std::string output;
    bool pending = false;
    for (size_t i = 0; i < length; i++) {
        unsigned char c = value[i];
        bool space = c == '\t' || c == '\n' || c == '\f' || c == '\r' || c == ' ';
        if (space) {
            pending = !output.empty();
            continue;
        }
        if (pending)
            output += ' ';
        pending = false;
        output += static_cast<char>(c);
    }
    return output;
}

static void find_canonical(lxb_dom_node_t *node, std::vector<std::string> &canonicals) {
    if (node->type == LXB_DOM_NODE_TYPE_ELEMENT) {
        auto element = lxb_dom_interface_element(node);
        if (tag_is(element, "link") && attr(element, "rel") == "canonical") {
            canonicals.push_back(attr(element, "href"));
        }
    }

    for (auto child = node->first_child; child != nullptr; child = child->next) {
        find_canonical(child, canonicals);
    }
}

static void collect(lxb_dom_node_t *node, const std::string &source,
                    std::vector<Row> &rows) {
    bool inside_template = false;
    if (node->type == LXB_DOM_NODE_TYPE_ELEMENT) {
        auto element = lxb_dom_interface_element(node);
        inside_template = tag_is(element, "template");

        if (has_attr(element, "rdf-predicate")) {
            auto subject = resolve(source, attr(element, "rdf-subject"));
            auto predicate = resolve(source, attr(element, "rdf-predicate"));

            // The generated profile identifies a native attribute carrying the value.
            const CarrierRule *carrier = nullptr;
            for (const auto &rule : CARRIER_RULES) {
                if (tag_is(element, rule.element) && has_attr(element, rule.attribute.data())) {
                    if (carrier != nullptr) {
                        throw std::runtime_error("Conflicting value carriers");
                    }
                    carrier = &rule;
                }
            }

            // IRI carrier values are references resolved against the semantic source.
            if (carrier != nullptr && carrier->kind == "iri") {
                auto object = resolve(source, attr(element, carrier->attribute.data()));
                rows.push_back({subject, predicate, "I", object, "", "", ""});
            } else {
                // Literal carriers are exact; other elements use normalized text.
                std::string value;
                if (carrier != nullptr && carrier->kind == "literal") {
                    value = attr(element, carrier->attribute.data());
                } else {
                    size_t length = 0;
                    auto text = lxb_dom_node_text_content(node, &length);
                    value = normalize(text, length);
                }

                // lang and dir apply only when directly present on this carrier.
                auto language = attr(element, "lang");
                auto direction = attr(element, "dir");
                std::ranges::transform(direction, direction.begin(),
                                       [](unsigned char c) { return std::tolower(c); });
                if (direction != "ltr" && direction != "rtl") {
                    direction.clear();
                }

                // Explicit datatype wins; direction otherwise selects dirLangString.
                std::string datatype = direction.empty() ? XSD_STRING : RDF_DIR_LANG_STRING;
                if (has_attr(element, "rdf-datatype")) {
                    datatype = resolve(source, attr(element, "rdf-datatype"));
                }
                rows.push_back(
                    {subject, predicate, "L", value, datatype, language, direction});
            }
        }
    }

    if (!inside_template) {
        for (auto child = node->first_child; child != nullptr; child = child->next) {
            collect(child, source, rows);
        }
    }
}

int main(int argc, char **argv) {
    if (argc != 2) {
        return 1;
    }

    // Fetch and parse the document as HTML.
    std::string html;
    auto curl = curl_easy_init();
    curl_easy_setopt(curl, CURLOPT_URL, argv[1]);
    curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
    curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, receive);
    curl_easy_setopt(curl, CURLOPT_WRITEDATA, &html);
    if (curl_easy_perform(curl) != CURLE_OK) {
        return 1;
    }
    char *retrieval = nullptr;
    curl_easy_getinfo(curl, CURLINFO_EFFECTIVE_URL, &retrieval);
    auto document = lxb_html_document_create();
    if (document == nullptr ||
        lxb_html_document_parse(document, reinterpret_cast<const lxb_char_t *>(html.data()),
                                html.size()) != LXB_STATUS_OK) {
        return 1;
    }

    // Resolve semantic references against the one canonical source IRI.
    std::vector<std::string> canonicals;
    find_canonical(lxb_dom_interface_node(document), canonicals);
    if (canonicals.size() != 1) {
        throw std::runtime_error("Expected one canonical link");
    }
    auto source = resolve(retrieval, canonicals.front());

    // Walk the tree to find every locally complete statement carrier.
    std::vector<Row> rows;
    collect(lxb_dom_interface_node(document), source, rows);
    std::ranges::sort(rows, {}, [](const Row &row) { return row[1]; });

    for (const auto &row : rows) {
        for (size_t i = 0; i < row.size(); i++) {
            std::cout << row[i] << (i + 1 == row.size() ? '\n' : '\t');
        }
    }
    lxb_html_document_destroy(document);
    curl_easy_cleanup(curl);
    return 0;
}
ElixirFetch: ReqParser: Floki + html5everView source
elixir/lib/html_rdf_example.exGitHub
defmodule HtmlRdfExample do
  @xsd_string "http://www.w3.org/2001/XMLSchema#string"
  @rdf_dir_lang_string "http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString"

  def main([url]) do
    # Fetch and parse the document as HTML5.
    response = Req.get!(url)
    {:ok, document} = Floki.parse_document(response.body, html_parser: Floki.HTMLParser.Html5ever)

    # Resolve semantic references against the one canonical source IRI.
    canonical =
      document
      |> Floki.find("head link[rel][href]")
      |> Enum.filter(fn {_tag, attrs, _children} ->
        attrs
        |> attr("rel")
        |> String.split()
        |> Enum.any?(fn token -> String.downcase(token) == "canonical" end)
      end)

    if length(canonical) != 1 do
      raise "Expected one canonical link"
    end

    {_tag, canonical_attrs, _children} = hd(canonical)
    source_iri = resolve(url, attr(canonical_attrs, "href"))

    # Attribute-name discovery finds every locally complete carrier.
    document
    |> collect_statements()
    |> Enum.map(&row(&1, source_iri))
    |> Enum.sort_by(&Enum.at(&1, 1))
    |> Enum.each(&IO.puts(Enum.join(&1, "\t")))
  end

  defp collect_statements(nodes) when is_list(nodes) do
    Enum.flat_map(nodes, &collect_statements/1)
  end

  defp collect_statements({"template", _attrs, _children}), do: []

  defp collect_statements({tag, attrs, children} = node) when is_binary(tag) do
    own = if attr(attrs, "rdf-predicate") == "", do: [], else: [node]
    own ++ collect_statements(children)
  end

  defp collect_statements(_node), do: []

  defp row({tag, attrs, _children} = node, source_iri) do
    subject = resolve(source_iri, attr(attrs, "rdf-subject"))
    predicate = resolve(source_iri, attr(attrs, "rdf-predicate"))

    # The generated profile identifies any native attribute carrying the value.
    carriers =
      CarrierProfile.rules()
      |> Enum.filter(fn {element, attribute, _kind, _operation} ->
        element == tag and has_attr?(attrs, attribute)
      end)

    if length(carriers) > 1 do
      raise "Conflicting value carriers on <#{tag}>"
    end

    carrier = List.first(carriers)

    # IRI carrier values are references; resolve them against the semantic source.
    if carrier != nil and elem(carrier, 2) == "iri" do
      attribute = elem(carrier, 1)
      [subject, predicate, "I", resolve(source_iri, attr(attrs, attribute)), "", "", ""]
    else
      # Literal carriers are exact strings. Other elements use normalized visible text.
      value =
        if carrier != nil and elem(carrier, 2) == "literal" do
          attr(attrs, elem(carrier, 1))
        else
          node |> Floki.text() |> normalize()
        end

      # lang and dir apply only when directly present on this carrier.
      language = attr(attrs, "lang")
      raw_direction = attrs |> attr("dir") |> String.downcase()
      direction = if raw_direction in ["ltr", "rtl"], do: raw_direction, else: ""

      # An explicit datatype wins; direction otherwise selects rdf:dirLangString.
      datatype =
        cond do
          attr(attrs, "rdf-datatype") != "" -> resolve(source_iri, attr(attrs, "rdf-datatype"))
          direction != "" -> @rdf_dir_lang_string
          true -> @xsd_string
        end

      [subject, predicate, "L", value, datatype, language, direction]
    end
  end

  defp attr(attrs, name) do
    Enum.find_value(attrs, "", fn {key, value} ->
      if key == name, do: value
    end)
  end

  defp has_attr?(attrs, name), do: Enum.any?(attrs, fn {key, _value} -> key == name end)

  defp normalize(value), do: value |> String.replace(~r/[\t\n\f\r ]+/, " ") |> String.trim()
  defp resolve(base, reference), do: base |> URI.parse() |> URI.merge(reference) |> to_string()
end

Preserve the template boundary. Browser-like DOM implementations keep template contents in a separate DocumentFragment. Tree-oriented libraries may expose those nodes as ordinary descendants, so their examples explicitly suppress candidates below a template during document extraction.

Treat the example closest to your stack as a starting point, not as a complete extractor. Adapt its fetch policy, error handling, and RDF term representation to your application, then extend it with every carrier, blank-node keys, named and empty graphs, nested RDF 1.2 triple terms, diagnostics, and the exact IRI rules in the normative specification. The example directory includes dependency manifests, generated native tables, and commands for running each version. The canonical Turtle profile and its deterministic compiler are also available on GitHub.

Describe the document as a graph, then render it.

RDF/HTML is the complementary direction to HTML/RDF. Turtle or TriG describes parsed document nodes, element classes, attributes, text, and enough child-order constraints to derive a sibling sequence. A renderer derives faithful ordinary HTML and can also publish RDF statements not used structurally inside an augmented HTML/RDF result.

Why describe HTML in RDF?

HTML is an excellent finished document format, but its tree is often the end of a pipeline. RDF/HTML is useful when the document should remain a view of a larger graph. Giving document nodes RDF identity lets the same dataset describe what a node renders, what domain resource it concerns, where it came from, and how it relates to evidence or decisions outside the page.

  • Graph-native views: derive a human-readable document from RDF without reducing the source graph to template variables or an opaque HTML string.
  • Independent composition: let separate graphs or tools contribute named nodes, annotations, and ordering constraints before one document is materialized.
  • Meaning survives rendering: consume the structural statements needed to build the DOM, then carry the remaining domain and provenance statements into the result as HTML/RDF.

It is not a replacement for ordinary HTML authoring. Use HTML directly when the page is the primary artifact and no graph-native composition, node identity, or preserved RDF context is needed.

HTML/RDF
Start with an HTML host and extract its RDF 1.2 dataset.
RDF/HTML
Start with an RDF 1.2 dataset and derive one or more HTML documents.
Fidelity
The graph describes the parsed DOM, including explicit text and comment nodes, not the original source bytes.
Safety
The public renderer and extension use inert previews. Description never grants execution.

Declare the vocabulary snapshot

Every renderable document identifies the dated vocabulary it was authored against. The renderer recognizes supported snapshots locally; it does not fetch the vocabulary during rendering.

Turtle
@prefix dcterms: <http://purl.org/dc/terms/> .

doc:page
  a rdfhtml:Document ;
  rdfhtml:base <https://example.com/page/> ;
  dcterms:conformsTo <https://ia2.dev/spec/rdf-html/vocabulary/rdf-html-2026-07-18.ttl> .

A dataset may contain partial RDF/HTML descriptions assembled by several sources. Materialization begins only when the selected document has one complete, unambiguous tree. The renderer reports incomplete membership or ordering instead of inventing missing structure.

Write known attributes directly

Each indexed HTML attribute has a generated RDF property. Use that property directly and preserve the exact DOM value as a string. Present boolean attributes use the empty string.

Turtle
@prefix rdfhtml: <https://ia2.dev/spec/rdf-html#> .

doc:save
  a rdfhtml:Button ;
  rdfhtml:id "save-draft" ;
  rdfhtml:accessKey "S" ;
  rdfhtml:disabled "" .

Use an expanded rdfhtml:Attribute occurrence for an unknown or extension attribute, or when the occurrence itself needs identity, provenance, or annotations. Known attributes can also use the expanded form with rdfhtml:attributeDefinition, but the direct property is the ordinary authoring form.

Extension attribute
doc:save rdfhtml:attribute [
  a rdfhtml:Attribute ;
  rdfhtml:attributeName "data-review-state" ;
  rdfhtml:attributeValue "pending"
] .

Use a collection for a complete child sequence

rdfhtml:children is the compact default when one description owns a parent's complete sequence. Its RDF Collection states membership and immediate order together. Anonymous [] nodes remain convenient for children that need no identity outside that description, and converters emit this form.

Turtle
doc:body rdfhtml:children (
  [ a rdfhtml:H1 ; rdfhtml:children (
    [ a rdfhtml:Text ; rdfhtml:data "Welcome" ]
  ) ]
  [ a rdfhtml:P ; rdfhtml:children (
    [ a rdfhtml:Text ; rdfhtml:data "Hello world!" ]
  ) ]
) .

Use flat relations for independent contributions

A collection is intentionally complete and changing its middle means changing the shared RDF list spine. Use named children, rdfhtml:hasChild, and ord: relations when different graphs or tools contribute nodes independently, when a child is annotated elsewhere, or when only relative order is meaningful. One rdfhtml:hasChild assertion anchors a connected ordering chain; normalization derives the other child memberships. The vocabulary supplies the ordering types, so authors do not repeat them.

Independently extensible Turtle
@prefix ord: <https://ontology.inferal.com/modules/ordering/> .

doc:body rdfhtml:hasChild doc:heading .

doc:heading
  a rdfhtml:H1 ;
  ord:precedes doc:status .

doc:status
  a rdfhtml:P ;
  ord:precedes doc:actions .

doc:actions
  a rdfhtml:Nav .

Use ord:immediatelyPrecedes only when adjacency is part of the assertion. If the important fact is merely that one child comes before another, ord:precedes, or inverse ord:follows, is usually more accurate and remains valid if another contributor inserts a child between them. A renderable parent must still have enough combined constraints to yield one sequence; the renderer does not invent a tie-break. The inverse forms rdfhtml:childOf and ord:follows are equivalent when that direction reads more naturally.

Keep mixed content explicit. Do not use an aggregate textContent value. Represent text as rdfhtml:Text nodes and order those nodes beside elements, so Hello <em>world</em>! remains three distinct children.

Convert an existing HTML document

The package can walk a parsed HTML DOM and emit the same compact RDF/HTML structure. It preserves document type, elements, attributes, text, comments, and template contents. The result describes the parsed DOM, so it does not preserve source whitespace choices or other byte-level formatting.

Command line
npx @ia2-dev/rdf-html describe page.html \
  --output page.ttl \
  --document-iri https://example.com/page#document \
  --base https://example.com/page/

The same command accepts an HTTP or HTTPS URL. In the other direction, npx @ia2-dev/rdf-html render page.ttl writes the faithful described HTML to standard output. Add --publication to retain unconsumed RDF in an HTML/RDF island. Use --document for one document in a multi-document Turtle or TriG source, or --workspace for the inert selector workspace.

Renderability is not complete HTML validation. Generated categories, attribute applicability, and value syntax are inspectable metadata. RDF/HTML validates that one DOM can be derived; a separate reviewed profile or HTML validator checks whether that DOM follows all WHATWG authoring rules.

Open a public Turtle or TriG document

The URL renderer fetches at most 2 MiB from a public HTTP or HTTPS location, blocks private-network destinations and unsafe redirects, then renders a single declared document without visible renderer chrome. When a source describes several documents, a compact selector remains outside the sandboxed document frame. The IA² browser extension applies the package's inert workspace renderer to browser-opened .ttl and .trig documents.

Carry a graph and its resources in one HTML document.

HARE, the proposed HTML Agent Resource Envelope, packages a manifest, relationships, and inspectable representations in a browser-openable file. Semantic HTML can live directly in the document as template DOM. The manifest is HTML/RDF, not a parallel JSON document, so agents can extract it without running code.

Use HARE when a handoff must preserve meaning and the resources that meaning describes. Keep the graph as the primary model. Give DOM representations logical paths so they receive virtual document URLs; paths remain optional for other representations. Use byte representations only when exact octets, downloads, or hashes matter. The envelope remains useful as HTML even when no viewer runs.

HARE reuses dcterms:hasPart for membership, dcterms:identifier for logical paths, and dc:format for media types. It defines only the envelope mechanics that those vocabularies do not provide, including the manifest graph, virtual document base, representation link, carrier, and optional runtime. Byte representations additionally use cred:digestSRI for digests and HARE's decoded byte length.

Envelope
The self-contained HTML artifact and its source document identity.
Manifest
One named RDF graph describing resources, representations, relationships, and policy.
Resource
A conceptual item in the handoff, such as a brief, dataset, image, or decision.
Representation
A semantic or byte realization of a resource, with a media type and an HTML carrier.

Build the smallest declarative envelope

This complete envelope carries a small HTML resource as parsed, directly inspectable template DOM. The logical path is a useful alias, not the identity or source of truth for the resource.

HARE 0.1
<!doctype html>
<html lang="en" rdf-version="1.2">
  <head>
    <meta charset="utf-8">
    <link rel="canonical" href="https://example.com/hello.hare.html">
  </head>
  <body>
    <main>
      <h1>Hello handoff</h1>
      <section id="manifest" aria-label="Bundle manifest">
        <a hidden href="https://ia2.dev/spec/resource-envelope#Envelope"
          rdf-subject="" rdf-predicate="http://www.w3.org/1999/02/22-rdf-syntax-ns#type"
          rdf-graph="#manifest"></a>
        <a hidden href="https://ia2.dev/spec/resource-envelope#HARE-0.1" rdf-subject=""
          rdf-predicate="http://purl.org/dc/terms/conformsTo"
          rdf-graph="#manifest"></a>
        <a hidden href="https://ia2.dev/spec/resource-envelope#DeclarativeProfile" rdf-subject=""
          rdf-predicate="http://purl.org/dc/terms/conformsTo"
          rdf-graph="#manifest"></a>
        <a hidden href="#manifest" rdf-subject=""
          rdf-predicate="https://ia2.dev/spec/resource-envelope#manifestGraph"
          rdf-graph="#manifest"></a>
        <a hidden href="https://hello.hare.invalid/" rdf-subject=""
          rdf-predicate="https://ia2.dev/spec/resource-envelope#virtualBase"
          rdf-graph="#manifest"></a>
        <a href="#greeting" rdf-subject=""
          rdf-predicate="http://purl.org/dc/terms/hasPart"
          rdf-graph="#manifest">Greeting resource</a>

        <a hidden href="#greeting-representation" rdf-subject="#greeting"
          rdf-predicate="https://ia2.dev/spec/resource-envelope#representation"
          rdf-graph="#manifest"></a>
        <a id="greeting-representation" hidden
          href="https://ia2.dev/spec/resource-envelope#DOMRepresentation"
          rdf-subject="#greeting-representation"
          rdf-predicate="http://www.w3.org/1999/02/22-rdf-syntax-ns#type"
          rdf-graph="#manifest"></a>
        <data value="/hello.html" rdf-subject="#greeting-representation"
          rdf-predicate="http://purl.org/dc/terms/identifier"
          rdf-graph="#manifest">/hello.html</data>
        <data hidden value="text/html" rdf-subject="#greeting-representation"
          rdf-predicate="http://purl.org/dc/elements/1.1/format"
          rdf-graph="#manifest"></data>
        <a hidden href="#greeting-content" rdf-subject="#greeting-representation"
          rdf-predicate="https://ia2.dev/spec/resource-envelope#carrier"
          rdf-graph="#manifest"></a>
      </section>
    </main>
    <template id="greeting-content">
      <article>
        <h1>Hello, agent</h1>
        <p>This semantic HTML is readable directly in the envelope.</p>
        <a href="/">Return to the envelope document</a>
      </article>
    </template>
  </body>
</html>

The declared hare:virtualBase gives the host envelope the virtual URL https://hello.hare.invalid/. The greeting's logical path gives it https://hello.hare.invalid/hello.html. Inside the template, href="/" returns to the host document, and ordinary relative paths can address other bundled representations. These virtual URLs are routing addresses, not retrievable locations or RDF identity.

When exact bytes are part of the handoff contract, use a hare:ByteRepresentation instead. Its carrier is an inert base64 <script type="application/octet-stream"> and its byte length and SRI digest describe the decoded octets. One resource may have both semantic and byte representations.

Materialize a small site without inventing an origin

Template HTML can use ordinary relative URLs for passive resources. A materializing viewer resolves each reference against the containing document's virtual URL, finds the matching byte representation, verifies it, and rewrites only the derived view to a local Blob URL. The template stays readable and unchanged.

Inspectable HTML with bundled dependencies
<template id="report-content">
  <link rel="stylesheet" href="../styles/report.css">
  <article>
    <img src="../images/verified.svg" alt="Verified">
    <a href="details.html">Details</a>
  </article>
</template>

Navigation and materialization deliberately differ. The anchor routes to another virtual document when activated; the image and stylesheet are passive fetch destinations and may be materialized immediately. Stylesheet imports and CSS url() references can recurse through the same verified process. External, missing, incompatible, and executable destinations stay inert; a viewer does not use the live network as fallback.

Verified Markdown bytes can use the same virtual address space. For example, a brief at /docs/brief.md may contain ![Verified result](../assets/verified.svg). A safe renderer resolves that reference to /assets/verified.svg, verifies the declared image representation, and gives only the rendered image a temporary Blob URL. The Markdown bytes and reference remain unchanged; unmatched images are never fetched from the network.

The optional @ia2-dev/hare-viewer runtime includes a small dependency-free Markdown renderer for headings, emphasis, links, images, quotations, lists, task items, tables, and code. It creates DOM nodes directly, treats raw HTML as text, routes links through HARE, and delegates images to verified resource lookup. It is intentionally not advertised as CommonMark conformant until it is tested against that specification's corpus. Self-viewing envelopes can inline the renderer with the rest of their runtime.

A safe viewer also makes links inert in its derived iframe before showing it, retains the original href as routing data, and resolves that value through the HARE virtual URL space when the user activates the link. This prevents a timing gap from turning a virtual .invalid address into a browser navigation. The readable links in the authored template are not changed.

The authored host document can refer to a bundled passive resource too. Keep its virtual URL in an inert data-hare-src attribute so the browser does not try the non-retrievable address before verification. A supporting viewer retains that declarative reference and supplies the verified Blob URL to src at runtime.

Verified image in the host document
<figure>
  <img
    data-hare-src="https://hello.hare.invalid/images/verified.svg"
    alt="Verified bundle"
  >
  <figcaption>Image carried by this envelope.</figcaption>
</figure>

Keep the host policy compatible. A srcdoc preview inherits the envelope's Content Security Policy, and its inner policy can only restrict it further. If browser viewers are intended to work, permit about: and blob: frames plus blob:/data: for the passive style, image, media, and font destinations the viewer materializes. Continue to deny scripts, workers, plugins, and network fallback.

DevTools, test runners, and other extensions sometimes inject inspection code into the preview. The browser may report that attempt as blocked script execution even though the derived document is script-free. That is the sandbox working. Confirm the derived document contains no script; do not add allow-scripts to silence the message.

Enhance it only when a viewer helps

A declarative envelope needs no executable code. During development, @ia2-dev/hare-viewer can generate a file browser when the body has no authored interface, or add Document and Files tabs when authored content is present. A compatible browser extension can add those modes automatically, including the full file browser for a bare envelope, without requiring or executing envelope-supplied code.

Development convenience
<script
  type="module"
  src="https://ia2.dev/packages/hare-viewer/dist/hare-viewer.js"
></script>

Keep transport self-contained. The package URL is convenient while authoring. Inline the built runtime into an exported self-viewing envelope, and never execute bundled HTML or scripts by default.

Give an agent the model, not a pasted prompt.

The IA² repository packages HTML/RDF and HARE as two portable Agent Skills. Each skill keeps its working instructions in SKILL.md, then loads detailed references or the HARE byte-commitment helper only when the task needs them.

ia2-html-rdf
Author, extract, and review locally complete RDF 1.2 statements in HTML.
ia2-hare
Create and verify HARE manifests, representations, byte commitments, routing, and viewer security.
plugins/ia2
The distributable package, with thin Codex and Claude Code manifests around the same skill folders.

Get the portable source

Clone the repository once. The directories under plugins/ia2/skills/ are the cross-client source of truth.

Shell
git clone --depth 1 https://github.com/ia2-dev/ia2.git
cd ia2
Codex / project skills
mkdir -p .agents/skills
cp -R plugins/ia2/skills/ia2-html-rdf .agents/skills/
cp -R plugins/ia2/skills/ia2-hare .agents/skills/
Invoke $ia2-html-rdf $ia2-hare
Claude Code / development plugin
claude --plugin-dir ./plugins/ia2
Invoke /ia2:ia2-html-rdf /ia2:ia2-hare

Install at the scope you mean

The Codex example installs repository-scoped skills. Copy the same two directories to ~/.agents/skills/ when they should be available to Codex across repositories. For persistent Claude Code project skills, copy them to .claude/skills/; use ~/.claude/skills/ for personal scope. Skills copied directly into Claude Code use /ia2-html-rdf and /ia2-hare without the plugin namespace.

Other Agent Skills compatible clients can install either skill directory in their supported skills location. The portable contract is the folder containing SKILL.md, its references, and optional scripts. The agents/openai.yaml files add Codex interface metadata but do not change the instructions.

Deploy the wrapper, maintain the core. The package includes both .codex-plugin/plugin.json and .claude-plugin/plugin.json for marketplace or team distribution. Keep edits in the shared skill folders so every host receives the same IA² and HARE behavior.

Check the statement at the carrier.

  • Declare rdf-version="1.2" on the document element.
  • Publish exactly one valid, fragmentless canonical link when a stable public identity is available and differs from the retrieval URL.
  • Use a full predicate IRI on every statement carrier.
  • Make every carrier determine exactly one object form.
  • Use explicit subjects for statements about another element or resource.
  • Put graph membership, datatype, language, and direction directly on the statement where they apply.
  • Inspect diagnostics, then verify the resulting Turtle and its DOM correlation.
Common mistakes

Expecting nesting to chain subjects. It never does.

Using prefixed names in attributes. Core resolves IRI references; it does not expand prefixes.

Combining object carriers. Do not put a native IRI, exact literal, local object key, and object template on the same statement.

Treating local keys as persistent IDs. They correlate blank nodes only within one extraction.

Assuming extraction validates or executes SHACL. Shapes and rules are extracted as RDF data; validation and execution are separate operations.

Move from the tour to the working artifacts.