From 52542bbed266d3574e1206a10dd18343a8986250 Mon Sep 17 00:00:00 2001 From: Ashley Caselli Date: Tue, 15 Sep 2026 14:41:34 +0200 Subject: [PATCH] fix(cite-as): claim it only on the page a nanopublication resolves to rel="cite-as" (RFC 8574) says which IRI the page being read is to be cited as. It sat in the markup of the SourceNanopub panel, which is the general "link to a source nanopublication" and renders wherever one is linked -- once per item in ItemListElement, for instance. A page listing n nanopublications therefore made n conflicting claims about itself, none of them its own identity, and a link to another item's source made a wrong one even on a single-item page. For dokieli, the reason #633 added the link, that is worse than not having it: an annotation would anchor to whichever nanopublication happened to be linked. Pages now say what they are to be cited as through getCiteAsIri(), which answers nothing unless the page is that resource's own page. ExplorePage answers with the nanopublication's IRI when the explored id resolves to one. Rendered into the head rather than onto the visible IRI link. A rel is a list of tokens and every token resolves against the vocabulary in scope, so putting the vocab on the visible link -- which carries noopener noreferrer for its target -- would assert two further link relations about the page. A link of its own carries the relation and nothing else. Closes #716 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NnGpEQkFqCaf4AEQwwhFFn --- .../nanodash/component/SourceNanopub.html | 2 +- .../nanodash/page/ExplorePage.java | 14 ++++ .../nanodash/page/NanodashPage.java | 40 ++++++++++ .../nanodash/page/CiteAsLinkTest.java | 76 +++++++++++++++++++ .../nanodash/page/CiteAsSubjectPage.html | 13 ++++ .../nanodash/page/CiteAsSubjectPage.java | 46 +++++++++++ 6 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/knowledgepixels/nanodash/page/CiteAsLinkTest.java create mode 100644 src/test/java/com/knowledgepixels/nanodash/page/CiteAsSubjectPage.html create mode 100644 src/test/java/com/knowledgepixels/nanodash/page/CiteAsSubjectPage.java diff --git a/src/main/java/com/knowledgepixels/nanodash/component/SourceNanopub.html b/src/main/java/com/knowledgepixels/nanodash/component/SourceNanopub.html index 2200af99..a96e68b8 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/SourceNanopub.html +++ b/src/main/java/com/knowledgepixels/nanodash/component/SourceNanopub.html @@ -3,7 +3,7 @@ - ^ + ^ diff --git a/src/main/java/com/knowledgepixels/nanodash/page/ExplorePage.java b/src/main/java/com/knowledgepixels/nanodash/page/ExplorePage.java index b3e4c6c2..280a71ac 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/ExplorePage.java +++ b/src/main/java/com/knowledgepixels/nanodash/page/ExplorePage.java @@ -94,8 +94,21 @@ public String getMountPath() { return MOUNT_PATH; } + /** + * {@inheritDoc} + */ + @Override + protected String getCiteAsIri() { + return citeAsIri; + } + private Nanopub publishedNanopub = null; + // Set when the explored id resolves to a nanopublication, i.e. when this is that + // nanopublication's own page. Only then does the page announce what to cite it as; a page + // about a term that merely mentions nanopublications announces nothing (issue #716). + private String citeAsIri = null; + /** * Constructor for ExplorePage. * @@ -196,6 +209,7 @@ private void initPage() { boolean isNanopubId = (np != null); if (isNanopubId) { tempRef = np.getUri().stringValue(); + citeAsIri = tempRef; } if (!isNanopubId && tempRef.matches("^(.*[^A-Za-z0-9-_])?RA[A-Za-z0-9-_]{43}[^A-Za-z0-9-_].*$")) { np = Utils.getAsNanopub(tempRef.replaceFirst("^(.*[^A-Za-z0-9-_])?(RA[A-Za-z0-9-_]{43})[^A-Za-z0-9-_].*$", "$2")); diff --git a/src/main/java/com/knowledgepixels/nanodash/page/NanodashPage.java b/src/main/java/com/knowledgepixels/nanodash/page/NanodashPage.java index 5b337c2e..4e3a1c13 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/NanodashPage.java +++ b/src/main/java/com/knowledgepixels/nanodash/page/NanodashPage.java @@ -372,6 +372,20 @@ protected RdfSource getRdfSource() { return null; } + /** + * The IRI this page should be cited as, or null where it should not be cited as anything. + *

+ * A page returns one only when it is that resource's own page -- the one its IRI + * resolves to. A page that merely links to a nanopublication is not announcing itself as + * that nanopublication, and a listing that links to many is announcing itself as none of + * them (issue #716). + * + * @return the IRI to be cited as, or null + */ + protected String getCiteAsIri() { + return null; + } + /** * Answers a client that asked for RDF in its {@code Accept} header with a 303 to the * download page in the matching format, and lets everyone else have the HTML (issue @@ -420,6 +434,31 @@ private void renderRdfLinks(IHeaderResponse response) { response.render(StringHeaderItem.forString("\n")); } + // The vocabulary the cite-as relation is a term of, scoped to the one link that uses it: + // with it in scope every token of that link's rel resolves against it, which is why the + // link carries the relation and nothing else (issues #633, #716). + private static final String LINK_RELATIONS_VOCAB = "https://www.w3.org/ns/iana/link-relations/relation#"; + + /** + * Renders the {@code cite-as} link (RFC + * 8574) that says which IRI this page is to be cited as, for pages that are a resource's + * own page. Annotation tools read it to anchor to the resource rather than to whichever URL + * the reader arrived by (issue #633). + *

+ * The {@code vocab} attribute lets an RDFa parser resolve the relation, as dokieli's does. + * Rendered into the head rather than onto the visible IRI link: a {@code rel} is a list of + * tokens and the visible link already carries {@code noopener noreferrer}, which the vocab + * would turn into link-relation triples of their own. + * + * @param response the header response to render into + */ + private void renderCiteAs(IHeaderResponse response) { + String citeAsIri = getCiteAsIri(); + if (citeAsIri == null || citeAsIri.isBlank()) return; + response.render(MetaDataHeaderItem.forLinkTag("cite-as", citeAsIri) + .addTagAttribute("vocab", LINK_RELATIONS_VOCAB)); + } + /** * Renders the description, canonical URL, Open Graph and Twitter card tags that * search engines and link previews read (issue #704). @@ -470,6 +509,7 @@ public void renderHead(IHeaderResponse response) { super.renderHead(response); renderPageMetadata(response); renderRdfLinks(response); + renderCiteAs(response); response.render(CssHeaderItem.forUrl(getStyleSheetUrl())); response.render(JavaScriptHeaderItem.forReference(getApplication().getJavaScriptLibrarySettings().getJQueryReference())); response.render(JavaScriptReferenceHeaderItem.forReference(nanodashJs)); diff --git a/src/test/java/com/knowledgepixels/nanodash/page/CiteAsLinkTest.java b/src/test/java/com/knowledgepixels/nanodash/page/CiteAsLinkTest.java new file mode 100644 index 00000000..117edcad --- /dev/null +++ b/src/test/java/com/knowledgepixels/nanodash/page/CiteAsLinkTest.java @@ -0,0 +1,76 @@ +package com.knowledgepixels.nanodash.page; + +import com.knowledgepixels.nanodash.WicketApplication; +import org.apache.wicket.util.tester.WicketTester; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The {@code cite-as} link of RFC 8574 says which IRI the page being read is to be cited as, + * so it belongs on a resource's own page and nowhere else: a page that merely links to + * nanopublications is not announcing itself as one of them (issues #633, #716). + */ +class CiteAsLinkTest { + + private WicketTester tester; + + @BeforeEach + void setUp() { + tester = new WicketTester(new WicketApplication()); + } + + private String render(Class pageClass) { + tester.startPage(pageClass); + return tester.getLastResponse().getDocument(); + } + + @Test + void aResourcesOwnPageSaysWhatToCiteItAs() { + String document = render(CiteAsSubjectPage.class); + assertTrue(document.contains("rel=\"cite-as\""), document); + assertTrue(document.contains("href=\"" + CiteAsSubjectPage.SUBJECT_IRI + "\""), document); + } + + // Without the vocabulary in scope an RDFa parser has no IRI to resolve the relation to, + // and dokieli -- the reason the link is here at all -- reads it through one. + @Test + void theRelationIsResolvableByAnRdfaParser() { + String document = render(CiteAsSubjectPage.class); + assertTrue(document.contains("vocab=\"https://www.w3.org/ns/iana/link-relations/relation#\""), document); + } + + // The link carries the relation and nothing else. Every token of a rel resolves against + // the vocabulary in scope, so a noopener alongside it would assert a link relation of its + // own about the page -- which is why this is a link of its own rather than an attribute on + // the visible IRI link, where noopener and noreferrer already sit. + @Test + void theCiteAsLinkCarriesNoOtherRelation() { + String document = render(CiteAsSubjectPage.class); + int relIndex = document.indexOf("rel=\"cite-as\""); + assertTrue(relIndex > -1, document); + int lineStart = document.lastIndexOf("<", relIndex); + int lineEnd = document.indexOf(">", relIndex); + String tag = document.substring(lineStart, lineEnd + 1); + assertFalse(tag.contains("noopener"), tag); + assertFalse(tag.contains("noreferrer"), tag); + } + + // A page with no subject of its own claims nothing, which is the case every listing and + // every page that merely links to a nanopublication falls into. + @Test + void aPageThatIsNotAResourcesOwnPageClaimsNothing() { + String document = render(ErrorPage.class); + assertFalse(document.contains("cite-as"), document); + } + + @Test + void exactlyOneCiteAsIsClaimed() { + String document = render(CiteAsSubjectPage.class); + assertEquals(1, document.split("rel=\"cite-as\"", -1).length - 1, document); + } + +} diff --git a/src/test/java/com/knowledgepixels/nanodash/page/CiteAsSubjectPage.html b/src/test/java/com/knowledgepixels/nanodash/page/CiteAsSubjectPage.html new file mode 100644 index 00000000..7c461a15 --- /dev/null +++ b/src/test/java/com/knowledgepixels/nanodash/page/CiteAsSubjectPage.html @@ -0,0 +1,13 @@ + + + + + Cite-as | nanodash + + + + +

cite-as test page

+ + + diff --git a/src/test/java/com/knowledgepixels/nanodash/page/CiteAsSubjectPage.java b/src/test/java/com/knowledgepixels/nanodash/page/CiteAsSubjectPage.java new file mode 100644 index 00000000..f152e7b1 --- /dev/null +++ b/src/test/java/com/knowledgepixels/nanodash/page/CiteAsSubjectPage.java @@ -0,0 +1,46 @@ +package com.knowledgepixels.nanodash.page; + +import com.knowledgepixels.nanodash.component.TitleBar; +import org.apache.wicket.markup.html.basic.Label; +import org.apache.wicket.markup.html.panel.EmptyPanel; +import org.apache.wicket.request.mapper.parameter.PageParameters; + +/** + * A stand-in for a page that is a resource's own page, used to render the {@code cite-as} + * link without reaching for a nanopublication over the network. + */ +public class CiteAsSubjectPage extends NanodashPage { + + /** The IRI this page claims to be cited as. */ + public static final String SUBJECT_IRI = "https://w3id.org/np/RAAbCdEfGhIjKlMnOpQrStUvWxYz0123456789-_AbCdE"; + + /** + * @param parameters the page parameters, unused + */ + public CiteAsSubjectPage(final PageParameters parameters) { + super(parameters); + try { + add(new TitleBar("titlebar", this)); + } catch (Exception ex) { + add(new EmptyPanel("titlebar")); + } + add(new Label("pagetitle", "Cite-as | nanodash")); + } + + /** + * {@inheritDoc} + */ + @Override + public String getMountPath() { + return "/cite-as-subject"; + } + + /** + * {@inheritDoc} + */ + @Override + protected String getCiteAsIri() { + return SUBJECT_IRI; + } + +}