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-graphorrdf-graph-keyis 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.
<!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>
<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.
<h1
id="alice"
rdf-predicate="https://schema.org/name"
>Alice</h1>
<#alice> <https://schema.org/name> "Alice" .<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 kind | Authoring form | Typical use |
|---|---|---|
| Visible literal | Element text | Names, descriptions, labels |
| Exact literal | meta[content], data[value], time[datetime] | Dates, codes, machine-exact values |
| IRI | Native href, src, cite, or other allowed single-IRI carrier | Links to people, artifacts, sources |
| Reusable blank node | rdf-object-key | Structured anonymous resources |
| RDF 1.2 triple term | One direct child template | Claims about an unasserted proposition |
IRI objects stay native links
<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
<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.
<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.
<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.
<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.
<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
contentexplicitly 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 / TypeScriptfetchjsdomView source
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`);
PythonHTTPXhtml5lib + lxmlView source
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))
JavaHttpClientjsoupView source
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)));
}
}
KotlinKtor ClientjsoupView source
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")) }
}
}
Scalasttp clientjsoupView source
//> 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# / .NETHttpClientAngleSharpView source
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));
}
Gonet/httpx/net/html + goqueryView source
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"))
}
}
Rustreqwestscraper + html5everView source
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+Symfony HttpClientDom\HTMLDocumentView source
<?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";
}
RubyFaradayNokogiri HTML5View source
# 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
SwiftURLSessionSwiftSoupView source
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()
Dartpackage:httppackage:htmlView source
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'));
}
}
ClibcurlLexborView source
#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++libcurlLexborView source
#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;
}
ElixirReqFloki + html5everView source
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.
@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.
@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.
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.
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.
@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.
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.
<!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.
<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
. 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.
<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.
<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.
git clone --depth 1 https://github.com/ia2-dev/ia2.git
cd ia2
mkdir -p .agents/skills
cp -R plugins/ia2/skills/ia2-html-rdf .agents/skills/
cp -R plugins/ia2/skills/ia2-hare .agents/skills/
$ia2-html-rdf
$ia2-hare
claude --plugin-dir ./plugins/ia2
/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.
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.