From 1eaad4cafb64b73276c96c951f3c272d2436b2b0 Mon Sep 17 00:00:00 2001 From: Ashley Caselli Date: Mon, 3 Aug 2026 16:45:51 +0200 Subject: [PATCH 1/2] feat: implement handling and rendering for literals with HTML datatype --- .../com/knowledgepixels/nanodash/Utils.java | 17 ++ .../nanodash/component/LiteralItem.html | 2 +- .../nanodash/component/LiteralItem.java | 12 +- .../nanodash/component/ReadonlyItem.java | 30 ++- .../knowledgepixels/nanodash/UtilsTest.java | 17 ++ .../nanodash/component/LiteralItemTest.java | 36 ++++ .../nanodash/template/HtmlDatatypeTest.java | 173 ++++++++++++++++++ 7 files changed, 283 insertions(+), 4 deletions(-) create mode 100644 src/test/java/com/knowledgepixels/nanodash/template/HtmlDatatypeTest.java diff --git a/src/main/java/com/knowledgepixels/nanodash/Utils.java b/src/main/java/com/knowledgepixels/nanodash/Utils.java index 15141a174..b7e162ea9 100644 --- a/src/main/java/com/knowledgepixels/nanodash/Utils.java +++ b/src/main/java/com/knowledgepixels/nanodash/Utils.java @@ -16,10 +16,12 @@ import org.eclipse.rdf4j.model.IRI; import org.eclipse.rdf4j.model.Literal; import org.eclipse.rdf4j.model.Statement; +import org.eclipse.rdf4j.model.Value; import org.eclipse.rdf4j.model.ValueFactory; import org.eclipse.rdf4j.model.impl.SimpleValueFactory; import org.eclipse.rdf4j.model.util.Literals; import org.eclipse.rdf4j.model.vocabulary.FOAF; +import org.eclipse.rdf4j.model.vocabulary.RDF; import org.eclipse.rdf4j.model.vocabulary.XSD; import org.nanopub.Nanopub; import org.nanopub.NanopubUtils; @@ -535,6 +537,10 @@ public static String sanitizeHtml(String rawHtml) { /** * Checks if a given string is likely to be HTML content. * + *

This is a heuristic, needed wherever the RDF datatype is not available (query + * results arrive as flat strings). Where an actual literal is at hand, prefer + * {@link #isHtmlLiteral(Value)}, which relies on the declared datatype instead. + * * @param value the string to check * @return true if the given string is HTML content, false otherwise */ @@ -542,6 +548,17 @@ public static boolean looksLikeHtml(String value) { return LEADING_TAG.matcher(value).find(); } + /** + * Checks whether a value is a literal explicitly tagged with the {@code rdf:HTML} + * datatype, i.e. whose content is meant to be rendered as HTML. + * + * @param value the value to check + * @return true if the value is an rdf:HTML literal, false otherwise + */ + public static boolean isHtmlLiteral(Value value) { + return value instanceof Literal literal && RDF.HTML.equals(literal.getDatatype()); + } + public static boolean isDate(String value) { return isDateLiteral(value) || isDateTimeLiteral(value); } diff --git a/src/main/java/com/knowledgepixels/nanodash/component/LiteralItem.html b/src/main/java/com/knowledgepixels/nanodash/component/LiteralItem.html index 790820105..e7f6e1b73 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/LiteralItem.html +++ b/src/main/java/com/knowledgepixels/nanodash/component/LiteralItem.html @@ -7,7 +7,7 @@ -"" + diff --git a/src/main/java/com/knowledgepixels/nanodash/component/LiteralItem.java b/src/main/java/com/knowledgepixels/nanodash/component/LiteralItem.java index c7b79b0b0..12fc95fc4 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/LiteralItem.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/LiteralItem.java @@ -1,7 +1,9 @@ package com.knowledgepixels.nanodash.component; +import com.knowledgepixels.nanodash.Utils; import com.knowledgepixels.nanodash.component.StatementItem.RepetitionGroup; import com.knowledgepixels.nanodash.template.UnificationException; +import org.apache.wicket.behavior.AttributeAppender; import org.apache.wicket.markup.html.basic.Label; import org.apache.wicket.markup.html.panel.Panel; import org.eclipse.rdf4j.model.Literal; @@ -31,7 +33,15 @@ public LiteralItem(String id, String parentId, Literal literal, RepetitionGroup super(id); this.literal = literal; // this.context = rg.getContext(); - add(new Label(LABEL_ID, literal.stringValue())); + if (Utils.isHtmlLiteral(literal)) { + // Content tagged as rdf:HTML is rendered as such, not shown as a quoted string + // (see issue #378). + add(new Label(LABEL_ID, Utils.sanitizeHtml(literal.stringValue())) + .setEscapeModelStrings(false) + .add(new AttributeAppender("class", "internal"))); + } else { + add(new Label(LABEL_ID, "\"" + literal.stringValue() + "\"")); + } } /** diff --git a/src/main/java/com/knowledgepixels/nanodash/component/ReadonlyItem.java b/src/main/java/com/knowledgepixels/nanodash/component/ReadonlyItem.java index be1b0dc98..dfe59ab48 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/ReadonlyItem.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/ReadonlyItem.java @@ -33,6 +33,7 @@ import org.eclipse.rdf4j.model.ValueFactory; import org.eclipse.rdf4j.model.impl.SimpleValueFactory; import org.eclipse.rdf4j.model.util.Literals; +import org.eclipse.rdf4j.model.vocabulary.RDF; import org.eclipse.rdf4j.model.vocabulary.XSD; import org.nanopub.Nanopub; import org.nanopub.NanopubUtils; @@ -473,18 +474,22 @@ public void unifyWith(Value v) throws UnificationException { linkComp.add(AttributeAppender.append("class", "long-literal collapsed")); showMoreLabelLiteral.setVisible(true); } + boolean renderAsHtml = renderAsHtml(vL); + // An rdf:HTML literal gets no datatype marker: the rendered content itself shows + // what it is, just as dates are shown without an "(xsd:date)" suffix. + boolean showDatatype = !vL.getDatatype().equals(XSD.STRING) && !renderAsHtml; if (vL.getLanguage().isPresent()) { model.setObject("\"" + vs + "\""); languageModel.setObject("(" + Literals.normalizeLanguageTag(vL.getLanguage().get()) + ")"); languageComp.setVisible(true); - } else if (!vL.getDatatype().equals(XSD.STRING)) { + } else if (showDatatype) { model.setObject("\"" + vs + "\""); datatypeModel.setObject("(" + vL.getDatatype().stringValue().replace(XSD.NAMESPACE, "xsd:") + ")"); datatypeComp.setVisible(true); } else { model.setObject("\"" + vs + "\""); } - if (Utils.looksLikeHtml(vs)) { + if (renderAsHtml) { linkComp.setVisible(false); extraModel.setObject(Utils.sanitizeHtml(vs)); extraComp.setEscapeModelStrings(false); @@ -495,6 +500,27 @@ public void unifyWith(Value v) throws UnificationException { } } + /** + * Decides whether a literal's content is to be rendered as HTML rather than escaped. + * + *

The datatype decides: {@code rdf:HTML} on the literal itself, or declared by the + * template for this placeholder, means HTML. Only when the template declares no + * datatype at all do we fall back to the pattern heuristic, which covers nanopubs + * published before HTML content was tagged, and those made without a template + * (see issue #378). + * + * @param literal the literal to be displayed + * @return true if the content is to be rendered as HTML + */ + private boolean renderAsHtml(Literal literal) { + if (Utils.isHtmlLiteral(literal)) return true; + // A language-tagged literal is an rdf:langString, so a datatype the template + // declares alongside the tag does not apply to it. + IRI declaredDatatype = literal.getLanguage().isPresent() ? null : template.getDatatype(iri); + if (declaredDatatype != null) return RDF.HTML.equals(declaredDatatype); + return Utils.looksLikeHtml(literal.stringValue()); + } + /** * Validator class for validating the input. */ diff --git a/src/test/java/com/knowledgepixels/nanodash/UtilsTest.java b/src/test/java/com/knowledgepixels/nanodash/UtilsTest.java index 397650b58..435ef784f 100644 --- a/src/test/java/com/knowledgepixels/nanodash/UtilsTest.java +++ b/src/test/java/com/knowledgepixels/nanodash/UtilsTest.java @@ -9,6 +9,7 @@ import org.eclipse.rdf4j.model.Statement; import org.eclipse.rdf4j.model.util.Values; import org.eclipse.rdf4j.model.vocabulary.FOAF; +import org.eclipse.rdf4j.model.vocabulary.RDF; import org.eclipse.rdf4j.model.vocabulary.XSD; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; @@ -137,6 +138,22 @@ void sanitizeHtmlPolicyPreservesAllowedProtocols() { assertEquals("Email", sanitizedHtml); } + @Test + void isHtmlLiteralRecognizesRdfHtmlDatatype() { + assertTrue(Utils.isHtmlLiteral(literal("

Hello

", RDF.HTML))); + assertTrue(Utils.isHtmlLiteral(literal("no tags at all", RDF.HTML)), + "the datatype decides, not the content"); + } + + @Test + void isHtmlLiteralRejectsOtherValues() { + assertFalse(Utils.isHtmlLiteral(literal("

Hello

")), "plain string literal"); + assertFalse(Utils.isHtmlLiteral(literal("

Hello

", "en")), "language-tagged literal"); + assertFalse(Utils.isHtmlLiteral(literal("42", XSD.INTEGER)), "other datatype"); + assertFalse(Utils.isHtmlLiteral(iri("https://example.org/thing")), "IRI"); + assertFalse(Utils.isHtmlLiteral(null), "null"); + } + @Test void isNanopubOfClass() throws MalformedNanopubException, NanopubAlreadyFinalizedException { Nanopub nanopub = TestUtils.createNanopub(); diff --git a/src/test/java/com/knowledgepixels/nanodash/component/LiteralItemTest.java b/src/test/java/com/knowledgepixels/nanodash/component/LiteralItemTest.java index 7e9765384..4b94a038c 100644 --- a/src/test/java/com/knowledgepixels/nanodash/component/LiteralItemTest.java +++ b/src/test/java/com/knowledgepixels/nanodash/component/LiteralItemTest.java @@ -5,6 +5,7 @@ import org.apache.wicket.util.tester.WicketTester; import org.eclipse.rdf4j.model.Literal; import org.eclipse.rdf4j.model.Value; +import org.eclipse.rdf4j.model.vocabulary.RDF; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -29,6 +30,41 @@ void testLiteralItemRendersLiteral() { tester.assertComponent("literalItem", LiteralItem.class); } + @Test + void htmlLiteralIsRendered() { + Literal literal = TestUtils.vf.createLiteral("

Hello world

", RDF.HTML); + LiteralItem item = new LiteralItem("literalItem", null, literal, null); + + tester.startComponentInPage(item); + String html = tester.getLastResponseAsString(); + assertTrue(html.contains("

Hello world

"), html); + assertFalse(html.contains("<p>"), html); + } + + @Test + void htmlLiteralIsSanitized() { + Literal literal = TestUtils.vf.createLiteral("

Hi

", RDF.HTML); + LiteralItem item = new LiteralItem("literalItem", null, literal, null); + + tester.startComponentInPage(item); + String html = tester.getLastResponseAsString(); + assertFalse(html.contains("onclick"), html); + assertFalse(html.contains("", RDF.HTML)); + // (the panel's own markup carries an onclick, so look for the injected payload) + assertFalse(html.contains("alert("), html); + assertFalse(html.contains("