From 6e0a5f17c84fd9ab7767f2a369187836b8a138c8 Mon Sep 17 00:00:00 2001 From: robyngit Date: Wed, 23 Oct 2024 15:25:16 -0400 Subject: [PATCH 1/3] Create model & view for schema.org metadata - Model can handle Dataset and DataCatalog types, and can import attributes from a template string. - Model includes methods for truncating or padding descriptions to meet Google's requirements. - Add unit tests for the model. Issue #1899 --- src/js/models/schemaOrg/SchemaOrg.js | 465 ++++++++++++++++++ src/js/views/schemaOrg/SchemaOrgView.js | 135 +++++ test/config/tests.json | 1 + .../unit/models/schemaOrg/SchemaOrg.spec.js | 153 ++++++ 4 files changed, 754 insertions(+) create mode 100644 src/js/models/schemaOrg/SchemaOrg.js create mode 100644 src/js/views/schemaOrg/SchemaOrgView.js create mode 100644 test/js/specs/unit/models/schemaOrg/SchemaOrg.spec.js diff --git a/src/js/models/schemaOrg/SchemaOrg.js b/src/js/models/schemaOrg/SchemaOrg.js new file mode 100644 index 000000000..709b48c0b --- /dev/null +++ b/src/js/models/schemaOrg/SchemaOrg.js @@ -0,0 +1,465 @@ +"use strict"; + +define(["backbone"], (Backbone) => { + // These limits come from the Google Dataset requirements, see: + // https://developers.google.com/search/docs/appearance/structured-data/dataset#structured-data-type-definitions + const MAX_DESCRIPTION_LENGTH = 5000; + const MIN_DESCRIPTION_LENGTH = 50; + + /** + * @class SchemaOrgModel + * @classdesc Creates a schema.org model for inserting JSON-LD into the + * document head. + * @classcategory Models/schemaOrg + * @since 0.0.0 + * @augments Backbone.Model + */ + const SchemaOrgModel = Backbone.Model.extend({ + /** @lends SchemaOrgModel.prototype */ + + /** @inheritdoc */ + defaults() { + return { + "@context": { + "@vocab": "https://schema.org/", + }, + }; + }, + + /** @inheritdoc */ + serialize() { + const json = this.toJSON(); + + // Pad or truncate description if too short or too long + if (json.description) { + json.description = this.adjustDescriptionLength(json.description); + } + + // Remove any empty properties + Object.keys(json).forEach((key) => { + if (!json[key]) { + delete json[key]; + } + // If it's an object, remove any empty properties. Remove entire object + // if all properties are empty. + if (typeof json[key] === "object") { + const obj = json[key]; + const keys = Object.keys(obj); + let empty = true; + keys.forEach((k) => { + if (obj[k]) { + empty = false; + } else { + delete obj[k]; + } + }); + if (empty) { + delete json[key]; + } + } + }); + return JSON.stringify(json); + }, + + /** + * Lengthens or shortens a description to fit within the limits of the + * ____ TODO + * @param {string} str - The description to adjust + * @returns {string} - The adjusted description + */ + adjustDescriptionLength(str) { + const link = this.get("url") || this.get("name") || "DataONE"; + // Note this string must be at least 50 characters long to add enough + // padding to very short descriptions + const descEnd = `Visit ${link} for complete metadata about this dataset.`; + let adjusted = this.truncateDescription(str, descEnd); + adjusted = this.padDescription(adjusted, descEnd); + return adjusted; + }, + + /** + * Truncates a description to a maximum length. Returns string unchanged if + * it is already shorter than the maximum length. + * @param {string} str - The description to truncate + * @param {string} descEnd - The text to append to the end of the + * description that has been truncated + * @returns {string} - The truncated description + */ + truncateDescription(str, descEnd) { + const descEndEllipsis = `... ${descEnd}`; + if (str.length > MAX_DESCRIPTION_LENGTH) { + const maxLength = MAX_DESCRIPTION_LENGTH - descEndEllipsis.length; + return str.slice(0, maxLength) + descEndEllipsis; + } + return str; + }, + /** + * Pads a description to a minimum length. Returns string unchanged if it is + * already longer than the minimum length. + * @param {string} str - The description to pad + * @param {string} descEnd - The text to append to the end of descriptions + * that are too short + * @returns {string} - The padded description + */ + padDescription(str, descEnd) { + let newStr = str; + if (str.length < MIN_DESCRIPTION_LENGTH) { + newStr = `${str}. ${descEnd}`; + } + return newStr; + }, + + /** + * Removes any previous attributes and sets new ones based on the type of + * page being viewed. + * @param {"Dataset"|"DataCatalog"} type - The type of page being viewed. If + * the type is neither "Dataset" nor "DataCatalog", the model is reset. + * @param {SolrResult} [model] - the model to get Dataset metadata from for + * "Dataset" type only + */ + setSchema(type, model = null) { + this.reset(true); + switch (type) { + case "Dataset": + this.setDatasetSchema(model); + break; + case "DataCatalog": + this.setDataCatalogSchema(); + break; + default: + this.reset(); + } + }, + + /** + * Given a stringified JSON template, set the attributes on this model from + * the template. + * @param {string} template - A stringified JSON template + */ + setSchemaFromTemplate(template) { + if (!template) { + this.reset(); + return; + } + if (typeof template === "string") { + this.set(JSON.parse(template)); + } + }, + + /** + * Reset the model to its default values + * @param {boolean} [silent] - Whether to suppress change events. Default is + * false. + */ + reset(silent = false) { + // Silient because this.set will trigger a change event, only need one. + this.clear({ silent: true }); + this.set(this.defaults(), { silent }); + }, + + /** + * Generate Schema.org-compliant JSONLD for a data catalog and set it on the + * model + */ + setDataCatalogSchema() { + const allNodes = MetacatUI.nodeModel.get("members"); + + const elJSON = { + "@type": "DataCatalog", + }; + + if (!allNodes || !allNodes.length) { + this.listenToOnce(MetacatUI.nodeModel, "change:members", () => { + // if the type is still DataCatalog, try again. Otherwise we've + // already switched to a different page. + if (this.get("@type") === "DataCatalog") { + this.setDataCatalogSchema(); + } + }); + this.set(elJSON); + return; + } + + const nodeId = MetacatUI.nodeModel.get("currentMemberNode"); + const node = allNodes.find((n) => n.identifier === nodeId); + + if (!node) { + this.set(elJSON); + return; + } + + this.set({ + "@type": "DataCatalog", + description: node.description, + identifier: node.identifier, + url: node.url, + name: node.name, + image: node.logo, + }); + }, + + /** + * Generate Schema.org-compliant JSONLD for a dataset and set it on the model + * @param {SolrResult} model The model to generate JSONLD for + */ + setDatasetSchema(model) { + if (!model) { + this.reset(); + this.set({ + "@type": "Dataset", + description: + "No description is available. Visit DataONE for complete metadata about this dataset.", + }); + return; + } + + const datasource = model.get("datasource"); + const id = model.get("id"); + const seriesId = model.get("seriesId"); + const url = `https://dataone.org/datasets/${encodeURIComponent(id)}`; + + const north = model.get("northBoundCoord"); + const east = model.get("eastBoundCoord"); + const south = model.get("southBoundCoord"); + const west = model.get("westBoundCoord"); + + const beginDate = model.get("beginDate"); + const endDate = model.get("endDate"); + + const title = model.get("title"); + const origin = model.get("origin"); + const attributeName = model.get("attributeName"); + const abstract = model.get("abstract"); + const keywords = model.get("keywords"); + + const DOIURL = this.getDOIURL(id, seriesId); + + // First: Create a minimal Schema.org Dataset with just the fields we + // know will come back from Solr (System Metadata fields). Add the rest + // in conditional on whether they are present. + const elJSON = { + "@type": "Dataset", + "@id": url, + datePublished: model.get("pubDate") || model.get("dateUploaded"), + dateModified: model.get("dateModified"), + publisher: { + "@type": "Organization", + name: MetacatUI.nodeModel.getMember(datasource)?.name || datasource, + }, + identifier: this.generateIdentifier(id, seriesId), + version: model.get("version"), + url, + schemaVersion: model.get("formatId"), + isAccessibleForFree: true, + }; + // Second: Add in optional fields + + if (DOIURL) elJSON.sameAs = DOIURL; + if (title) elJSON.name = model.get("title"); + + // Creator + if (origin) { + elJSON.creator = origin.map((creator) => ({ + "@type": "Person", + name: creator, + })); + } + + const spatial = this.generateSpatialCoverage(north, east, south, west); + if (spatial) { + elJSON.spatialCoverage = spatial; + } + + if (beginDate) { + elJSON.temporalCoverage = beginDate; + if (endDate) { + elJSON.temporalCoverage += `/${endDate}`; + } + } + + // Dataset/variableMeasured + if (attributeName) elJSON.variableMeasured = attributeName; + + // Dataset/description + if (abstract) { + elJSON.description = abstract; + } else { + elJSON.description = `No description is available. Visit ${url} for complete metadata about this dataset.`; + } + + // Dataset/keywords + if (keywords) { + elJSON.keywords = keywords.join(", "); + } + + this.set(elJSON); + }, + + /** + * Given a DOI and/or seriesId, return a URL to the DOI resolver + * @param {string} id The ID from the Solr index + * @param {string} seriesId The seriesId from the Solr index + * @returns {string|null} The URL to the DOI resolver or null if neither the id + * nor the seriesId is a DOI + */ + getDOIURL(id, seriesId) { + return ( + MetacatUI.appModel.DOItoURL(id) || MetacatUI.appModel.DOItoURL(seriesId) + ); + }, + + /** + * Generate a Schema.org/identifier from the model's id. Tries to use the + * PropertyValue pattern when the identifier is a DOI and falls back to a + * Text value otherwise + * @param {string} id The ID from the Solr index + * @param {string} seriesId The seriesId from the Solr index + * @returns {object|string} - A Schema.org/PropertyValue object or a string + */ + generateIdentifier(id, seriesId) { + // DOItoURL returns null if the string is not a DOI + const doiURL = this.getDOIURL(id, seriesId); + + if (!doiURL) return id; + + return { + "@type": "PropertyValue", + propertyID: "https://registry.identifiers.org/registry/doi", + value: doiURL.replace("https://doi.org/", "doi:"), + url: doiURL, + }; + }, + + /** + * Generate a Schema.org/Place/geo from bounding coordinates. Either + * generates a GeoCoordinates (when the north and east coords are the same) + * or a GeoShape otherwise. + * @param {number} north - North bounding coordinate + * @param {number} east - East bounding coordinate + * @param {number} south - South bounding coordinate + * @param {number} west - West bounding coordinate + * @returns {object} - A Schema.org/Place/geo object + */ + generateSpatialCoverage(north, east, south, west) { + if (!north || !east || !south || !west) return null; + let geo = { + "@type": "GeoShape", + box: `${west}, ${south} ${east}, ${north}`, + }; + if (north === south) { + geo = { + "@type": "GeoCoordinates", + latitude: north, + longitude: west, + }; + } + const spatialCoverage = { + "@type": "Place", + additionalProperty: [ + { + "@type": "PropertyValue", + additionalType: + "http://dbpedia.org/resource/Coordinate_reference_system", + name: "Coordinate Reference System", + value: "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + }, + ], + geo, + subjectOf: { + "@type": "CreativeWork", + fileFormat: "application/vnd.geo+json", + text: this.generateGeoJSONString(north, east, south, west), + }, + }; + return spatialCoverage; + }, + + /** + * Creates a (hopefully) valid geoJSON string from the a set of bounding + * coordinates from the Solr index (north, east, south, west). + * + * This function produces either a GeoJSON Point or Polygon depending on + * whether the north and south bounding coordinates are the same. + * + * Part of the reason for factoring this out, in addition to code + * organization issues, is that the GeoJSON spec requires us to modify the + * raw result from Solr when the coverage crosses -180W which is common + * for datasets that cross the Pacific Ocean. In this case, We need to + * convert the east bounding coordinate from degrees west to degrees east. + * + * e.g., if the east bounding coordinate is 120 W and west bounding + * coordinate is 140 E, geoJSON requires we specify 140 E as 220 + * @param {number} north - North bounding coordinate + * @param {number} east - East bounding coordinate + * @param {number} south - South bounding coordinate + * @param {number} west - West bounding coordinate + * @returns {string} - A stringified GeoJSON object + */ + generateGeoJSONString(north, east, south, west) { + if (north === south) { + return this.generateGeoJSONPoint(north, east); + } + return this.generateGeoJSONPolygon(north, east, south, west); + }, + + /** + * Generate a GeoJSON Point object + * @param {number} north - North bounding coordinate + * @param {number} east - East bounding coordinate + * @returns {string} - A stringified GeoJSON Point object + * @example + * { + * "type": "Point", + * "coordinates": [ + * -105.01621, + * 39.57422 + * ]} + */ + generateGeoJSONPoint(north, east) { + const preamble = '{"type":"Point","coordinates":'; + const inner = `[${east},${north}]`; + const postamble = "}"; + + return preamble + inner + postamble; + }, + + /** + * Generate a GeoJSON Polygon object from + * @param {number} north - North bounding coordinate + * @param {number} east - East bounding coordinate + * @param {number} south - South bounding coordinate + * @param {number} west - West bounding coordinate + * @returns {string} - A stringified GeoJSON Polygon object + * @example + * { + * "type": "Polygon", + * "coordinates": [[ + * [ 100, 0 ], + * [ 101, 0 ], + * [ 101, 1 ], + * [ 100, 1 ], + * [ 100, 0 ] + * ]} + */ + generateGeoJSONPolygon(north, east, south, west) { + const preamble = + '{"type":"Feature","properties":{},"geometry":{"type":"Polygon","coordinates":[['; + + // Handle the case when the polygon wraps across the 180W/180E boundary + const fixedEast = east < west ? 360 - east : east; + + const inner = + `[${west},${south}],` + + `[${fixedEast},${south}],` + + `[${fixedEast},${north}],` + + `[${west},${north}],` + + `[${west},${south}]`; + + const postamble = "]]}}"; + + return preamble + inner + postamble; + }, + }); + + return SchemaOrgModel; +}); diff --git a/src/js/views/schemaOrg/SchemaOrgView.js b/src/js/views/schemaOrg/SchemaOrgView.js new file mode 100644 index 000000000..d3337029a --- /dev/null +++ b/src/js/views/schemaOrg/SchemaOrgView.js @@ -0,0 +1,135 @@ +"use strict"; + +define(["backbone", "models/schemaOrg/SchemaOrg"], (Backbone, SchemaOrg) => { + const SCRIPT_TYPE = "application/ld+json"; + const JSON_LD_ID = "jsonld"; + /** + * @class SchemaOrgView + * @classdesc Inserts and updates the Schema.org JSON-LD script tag in the + * head of the document. This view will only work if the JSON-LD feature is + * enabled in the MetacatUI configuration. Otherwise, no JSON-LD script tag + * will be inserted into the head of the document. + * @classcategory Views/Maps + * @name SchemaOrgView + * @augments Backbone.View + * @since 0.0.0 + * @constructs SchemaOrgView + */ + const SchemaOrgView = Backbone.View.extend( + /** @lends SchemaOrgView.prototype */ { + /** @inheritdoc */ + initialize() { + if (!this.isEnabled()) return; + this.model = new SchemaOrg(); + this.stopListening(); + this.listenTo(this.model, "change", this.updateJsonldEl); + }, + + /** + * Checks if the JSON-LD feature is enabled in the MetacatUI configuration. + * @returns {boolean} True if the JSON-LD feature is enabled, false otherwise. + */ + isEnabled() { + return MetacatUI.appModel.get("isJSONLDEnabled"); + }, + + /** @inheritdoc */ + render() { + if (!this.isEnabled()) return; + this.removeExistingJsonldEls(); + this.addJsonldEl(); + }, + + /** + * Updates the JSON-LD script tag in the head of the document based on the + * type of page being viewed. + * @param {"Dataset"|"DataCatalog"} type - The type of page being viewed. + * If neither "Dataset" nor "DataCatalog" is provided, the schema will be + * set to the default schema. + * @param {object} model - The model to use for the schema. + * @since 0.0.0 + */ + setSchema(type, model = null) { + if (!this.isEnabled()) return; + this.model.setSchema(type, model); + }, + + /** + * Sets the schema based on a template. + * @param {string} template - The template to use for the schema. This + * must be stringified JSON that follows the schema.org schema. + */ + setSchemaFromTemplate(template) { + if (!this.isEnabled()) return; + if (typeof template === "string") { + this.model.setSchemaFromTemplate(template); + } + }, + + /** + * Create the JSON LD element to insert into the head of the document. + * @returns {HTMLScriptElement} The JSON LD element. + */ + createJsonldEl() { + const jsonldEl = document.createElement("script"); + jsonldEl.type = SCRIPT_TYPE; + jsonldEl.id = JSON_LD_ID; + this.jsonldEl = jsonldEl; + return jsonldEl; + }, + + /** + * Updates the JSON-LD script tag in the head of the document based on the + * model values. + */ + updateJsonldEl() { + if (!this.isEnabled()) return; + let el = this.jsonldEl; + if (!el) { + el = this.addJsonldEl(); + } + const text = this.model.serialize(); + el.text = text; + }, + + /** + * Inserts the JSON-LD script tag into the head of the document. + * @returns {HTMLScriptElement} The JSON-LD element. + */ + addJsonldEl() { + if (!this.isEnabled()) return null; + const el = this.createJsonldEl(); + document.head.appendChild(el); + return el; + }, + + /** + * Get all the existing JSON-LD elements in the head of the document. + * @returns {NodeListOf} The existing JSON-LD elements. + */ + getExistingJsonldEls() { + return document.head.querySelectorAll(`script[type="${SCRIPT_TYPE}"]`); + }, + + /** + * Removes any existing JSON-LD elements from the head of the document. + */ + removeExistingJsonldEls() { + const els = this.getExistingJsonldEls(); + els.forEach((el) => { + document.head.removeChild(el); + }); + this.jsonldEl = null; + }, + + /** What to do when the view is closed. */ + onClose() { + this.removeExistingJsonldEls(); + this.stopListening(); + this.model.destroy(); + }, + }, + ); + + return SchemaOrgView; +}); diff --git a/test/config/tests.json b/test/config/tests.json index 6eef2b901..c27812f4c 100644 --- a/test/config/tests.json +++ b/test/config/tests.json @@ -56,6 +56,7 @@ "./js/specs/unit/models/filters/SpatialFilter.spec.js", "./js/specs/unit/models/maps/Geohash.spec.js", "./js/specs/unit/models/maps/assets/CesiumGeohash.spec.js", + "./js/specs/unit/models/schemaOrg/SchemaOrg.spec.js", "./js/specs/unit/common/Utilities.spec.js", "./js/specs/unit/common/IconUtilities.spec.js", "./js/specs/unit/common/SearchParams.spec.js", diff --git a/test/js/specs/unit/models/schemaOrg/SchemaOrg.spec.js b/test/js/specs/unit/models/schemaOrg/SchemaOrg.spec.js new file mode 100644 index 000000000..f6433cb14 --- /dev/null +++ b/test/js/specs/unit/models/schemaOrg/SchemaOrg.spec.js @@ -0,0 +1,153 @@ +"use strict"; + +define(["/test/js/specs/shared/clean-state.js", "models/schemaOrg/SchemaOrg"], ( + cleanState, + SchemaOrg, +) => { + const should = chai.should(); + const expect = chai.expect; + + describe("SchemaOrg Test Suite", () => { + const state = cleanState(() => { + const schemaOrg = new SchemaOrg(); + return { schemaOrg }; + }, beforeEach); + + it("creates a SchemaOrg instance", () => { + state.schemaOrg.should.be.instanceof(SchemaOrg); + }); + + it("creates a default context", () => { + state.schemaOrg.get("@context").should.deep.equal({ + "@vocab": "https://schema.org/", + }); + }); + + it("serializes the model", () => { + const json = state.schemaOrg.serialize(); + json.should.be.a("string"); + }); + + it("adjusts description length", () => { + const str = "This is a description."; + const adjusted = state.schemaOrg.adjustDescriptionLength(str); + adjusted.should.be.a("string"); + adjusted.should.not.equal(str); + adjusted.length.should.be.at.least(50); + }); + + it("truncates a description", () => { + // Make a 5000+ character string + const str = "a".repeat(5001); + const descEnd = "This is the end."; + const truncated = state.schemaOrg.truncateDescription(str, descEnd); + truncated.should.be.a("string"); + truncated.should.not.equal(str); + truncated.length.should.be.at.most(5000); + truncated.should.include(descEnd); + }); + + it("pads a description", () => { + const str = "This is a description."; + const descEnd = + "This is the ending and it is at least 50 characters long."; + const padded = state.schemaOrg.padDescription(str, descEnd); + padded.should.be.a("string"); + padded.should.not.equal(str); + padded.length.should.be.at.least(50); + }); + + it("sets a schema", () => { + state.schemaOrg.setSchema("Dataset"); + state.schemaOrg.get("@type").should.equal("Dataset"); + }); + + it("sets a schema from a template", () => { + const template = JSON.stringify({ + "@type": "Dataset", + name: "Name", + }); + state.schemaOrg.setSchemaFromTemplate(template); + state.schemaOrg.get("name").should.equal("Name"); + }); + + it("resets the model", () => { + state.schemaOrg.set("name", "Name"); + state.schemaOrg.reset(); + should.not.exist(state.schemaOrg.get("name")); + }); + + it("sets a data catalog schema", () => { + state.schemaOrg.setDataCatalogSchema(); + state.schemaOrg.get("@type").should.equal("DataCatalog"); + }); + + it("sets a dataset schema", () => { + const model = new Backbone.Model({ + datasource: "DataONE", + id: "id", + seriesId: "seriesId", + northBoundCoord: 90, + eastBoundCoord: 180, + southBoundCoord: -90, + westBoundCoord: -180, + beginDate: "2021-01-01", + endDate: "2021-12-31", + title: "Title", + origin: ["Origin"], + attributeName: "Attribute", + abstract: "Abstract", + keywords: ["Keyword"], + }); + state.schemaOrg.setDatasetSchema(model); + state.schemaOrg.get("@type").should.equal("Dataset"); + state.schemaOrg.get("name").should.equal("Title"); + state.schemaOrg.get("variableMeasured").should.equal("Attribute"); + state.schemaOrg.get("description").should.equal("Abstract"); + }); + + it("gets a DOI URL", () => { + const doiURL = state.schemaOrg.getDOIURL("id", "seriesId"); + doiURL.should.equal(""); + }); + + it("generates an identifier", () => { + const identifier = state.schemaOrg.generateIdentifier("id", "seriesId"); + identifier.should.be.a("string"); + }); + + it("generates spatial coverage", () => { + const spatial = state.schemaOrg.generateSpatialCoverage( + 90, + 180, + -90, + -180, + ); + spatial.should.be.an("object"); + }); + + it("generates a GeoJSON string", () => { + const geoJSON = state.schemaOrg.generateGeoJSONString(90, 180, -90, -180); + geoJSON.should.be.a("string"); + }); + + it("generates a GeoJSON Point", () => { + const geoJSON = state.schemaOrg.generateGeoJSONPoint(90, 180); + geoJSON.should.be.a("string"); + geoJSON.should.include("Point"); + geoJSON.should.include("coordinates"); + }); + + it("generates a GeoJSON Polygon", () => { + const geoJSON = state.schemaOrg.generateGeoJSONPolygon( + 90, + 180, + -90, + -180, + ); + geoJSON.should.be.a("string"); + geoJSON.should.include("Polygon"); + geoJSON.should.include("Feature"); + }); + }); +}); From fca57358541786879fa44bf080eb1fd88ad47b62 Mon Sep 17 00:00:00 2001 From: robyngit Date: Wed, 23 Oct 2024 17:10:12 -0400 Subject: [PATCH 2/3] Use schema.org model/view & remove logic elsewhere - Remove logic for creating and inserting schema.org JSON-LD from AppView, DataCatalogView, MetadataView, and CatalogSearchView. - Use the SchemaOrg model that is set on the MetacatUI.AppView instead. - Move the jsonLD template importing to the SchemaOrgView. - Use the jsonLD template content in jsonLD tag as default for non-dataset and non-dataCatalog pages. Issue #1899 --- src/js/models/AppModel.js | 6 +- src/js/models/schemaOrg/SchemaOrg.js | 9 +- src/js/routers/router.js | 3 +- src/js/themes/arctic/routers/router.js | 3 +- src/js/views/AppView.js | 30 +-- src/js/views/DataCatalogView.js | 49 +--- src/js/views/MetadataView.js | 312 +---------------------- src/js/views/schemaOrg/SchemaOrgView.js | 20 +- src/js/views/search/CatalogSearchView.js | 60 +---- 9 files changed, 48 insertions(+), 444 deletions(-) diff --git a/src/js/models/AppModel.js b/src/js/models/AppModel.js index d5af3c6c3..e84e50ec9 100644 --- a/src/js/models/AppModel.js +++ b/src/js/models/AppModel.js @@ -1116,8 +1116,10 @@ define(["jquery", "underscore", "backbone"], function ($, _, Backbone) { searchMapTileHue: "192", /** - * If true, the dataset landing pages will generate Schema.org-compliant JSONLD - * and insert it into the page. + * If true, the dataset landing pages and data catalog view will + * generate Schema.org-compliant JSONLD and insert it into the page. + * If there is a JSONLD template for the app, it will also be + * inserted. This is useful for search engines and other web crawlers. * @type {boolean} * @default true */ diff --git a/src/js/models/schemaOrg/SchemaOrg.js b/src/js/models/schemaOrg/SchemaOrg.js index 709b48c0b..54a4a4df0 100644 --- a/src/js/models/schemaOrg/SchemaOrg.js +++ b/src/js/models/schemaOrg/SchemaOrg.js @@ -141,8 +141,13 @@ define(["backbone"], (Backbone) => { this.reset(); return; } - if (typeof template === "string") { - this.set(JSON.parse(template)); + try { + if (typeof template === "string") { + this.set(JSON.parse(template)); + } + } catch (e) { + this.model.set("parseError", e); + this.reset(); } }, diff --git a/src/js/routers/router.js b/src/js/routers/router.js index 8c714a915..920b43d92 100644 --- a/src/js/routers/router.js +++ b/src/js/routers/router.js @@ -771,7 +771,8 @@ define(["jquery", "underscore", "backbone"], function ($, _, Backbone) { }, clearJSONLD: function () { - $("#jsonld").remove(); + MetacatUI.appView.schemaOrg.removeExistingJsonldEls(); + MetacatUI.appView.schemaOrg.setSchemaFromTemplate(); }, clearHighwirePressMetaTags: function () { diff --git a/src/js/themes/arctic/routers/router.js b/src/js/themes/arctic/routers/router.js index 5ca40238a..0e1688fcb 100644 --- a/src/js/themes/arctic/routers/router.js +++ b/src/js/themes/arctic/routers/router.js @@ -638,7 +638,8 @@ define(["jquery", "underscore", "backbone"], function ($, _, Backbone) { }, clearJSONLD: function () { - $("#jsonld").remove(); + MetacatUI.appView.schemaOrg.removeExistingJsonldEls(); + MetacatUI.appView.schemaOrg.setSchemaFromTemplate(); }, clearHighwirePressMetaTags: function () { diff --git a/src/js/views/AppView.js b/src/js/views/AppView.js index 8d30e7d66..c65dd005e 100644 --- a/src/js/views/AppView.js +++ b/src/js/views/AppView.js @@ -6,9 +6,9 @@ "views/NavbarView", "views/FooterView", "views/SignInView", + "views/schemaOrg/SchemaOrgView", "text!templates/alert.html", "text!templates/appHead.html", - "text!templates/jsonld.txt", "text!templates/app.html", "text!templates/loading.html", ], function ( @@ -19,9 +19,9 @@ NavbarView, FooterView, SignInView, + SchemaOrgView, AlertTemplate, AppHeadTemplate, - JsonLDTemplate, AppTemplate, LoadingTemplate, ) { @@ -45,7 +45,6 @@ template: _.template(AppTemplate), alertTemplate: _.template(AlertTemplate), appHeadTemplate: _.template(AppHeadTemplate), - jsonLDTemplate: _.template(JsonLDTemplate), loadingTemplate: _.template(LoadingTemplate), events: { @@ -148,20 +147,17 @@ return; } - // set up the head - make sure to prepend, otherwise the CSS may be out of order! - $("head") - .append( - this.appHeadTemplate({ - theme: MetacatUI.theme, - }), - ) - //Add the JSON-LD to the head element - .append( - $(document.createElement("script")) - .attr("type", "application/ld+json") - .attr("id", "jsonld") - .html(this.jsonLDTemplate()), - ); + // set up the head + $("head").append( + this.appHeadTemplate({ + theme: MetacatUI.theme, + }), + ); + + // Add schema.org JSON-LD to the head + this.schemaOrg = new SchemaOrgView(); + this.schemaOrg.render(); + this.schemaOrg.setSchemaFromTemplate(); // set up the body this.$el.append(this.template()); diff --git a/src/js/views/DataCatalogView.js b/src/js/views/DataCatalogView.js index e2811e632..c32300c52 100644 --- a/src/js/views/DataCatalogView.js +++ b/src/js/views/DataCatalogView.js @@ -408,6 +408,8 @@ define([ this.addAnnotationFilter(); + MetacatUI.appView.schemaOrg.setSchema("DataCatalog"); + return this; }, @@ -438,53 +440,6 @@ define([ }); }, - // Linked Data Object for appending the jsonld into the browser DOM - getLinkedData() { - // Find the MN info from the CN Node list - const members = MetacatUI.nodeModel.get("members"); - for (let i = 0; i < members.length; i++) { - if ( - members[i].identifier == - MetacatUI.nodeModel.get("currentMemberNode") - ) { - var nodeModelObject = members[i]; - } - } - - // JSON Linked Data Object - const elJSON = { - "@context": { - "@vocab": "http://schema.org/", - }, - "@type": "DataCatalog", - }; - if (nodeModelObject) { - // "keywords": "", - // "provider": "", - const conditionalData = { - description: nodeModelObject.description, - identifier: nodeModelObject.identifier, - image: nodeModelObject.logo, - name: nodeModelObject.name, - url: nodeModelObject.url, - }; - $.extend(elJSON, conditionalData); - } - - // Check if the jsonld already exists from the previous data view - // If not create a new script tag and append otherwise replace the text for the script - if (!document.getElementById("jsonld")) { - const el = document.createElement("script"); - el.type = "application/ld+json"; - el.id = "jsonld"; - el.text = JSON.stringify(elJSON); - document.querySelector("head").appendChild(el); - } else { - const script = document.getElementById("jsonld"); - script.text = JSON.stringify(elJSON); - } - }, - /* * Sets the height on elements in the main content area to fill up the entire area minus header and footer */ diff --git a/src/js/views/MetadataView.js b/src/js/views/MetadataView.js index 1bcbed1b4..7acfcfe1d 100644 --- a/src/js/views/MetadataView.js +++ b/src/js/views/MetadataView.js @@ -560,10 +560,7 @@ define([ } else this.renderMetadataFromIndex(); // Insert the Linked Data into the header of the page. - if (MetacatUI.appModel.get("isJSONLDEnabled")) { - const json = this.generateJSONLD(); - this.insertJSONLD(json); - } + MetacatUI.appView.schemaOrg.setSchema("Dataset", this.model); this.insertCitationMetaTags(); }, @@ -3304,313 +3301,6 @@ define([ return this.model.get("dateUploaded"); }, - /** - * Generate Schema.org-compliant JSONLD for the model bound to the view - * into the head tag of the page by `insertJSONLD`. - * - * Note: `insertJSONLD` should be called to do the actual inserting into - * the DOM. - * @returns {object} - JSON-LD object for the model bound to the view - */ - generateJSONLD() { - const { model } = this; - - // First: Create a minimal Schema.org Dataset with just the fields we - // know will come back from Solr (System Metadata fields). Add the rest - // in conditional on whether they are present. - const elJSON = { - "@context": { - "@vocab": "https://schema.org/", - }, - "@type": "Dataset", - "@id": `https://dataone.org/datasets/${encodeURIComponent( - model.get("id"), - )}`, - datePublished: this.getDatePublishedText(), - dateModified: model.get("dateModified"), - publisher: { - "@type": "Organization", - name: this.getPublisherText(), - }, - identifier: this.generateSchemaOrgIdentifier(model.get("id")), - version: model.get("version"), - url: `https://dataone.org/datasets/${encodeURIComponent( - model.get("id"), - )}`, - schemaVersion: model.get("formatId"), - isAccessibleForFree: true, - }; - - // Attempt to add in a sameAs property of we have high confidence the - // identifier is a DOI - if (this.model.isDOI(model.get("id"))) { - const doi = this.getCanonicalDOIIRI(model.get("id")); - - if (doi) { - elJSON.sameAs = doi; - } - } - - // Second: Add in optional fields - - // Name - if (model.get("title")) { - elJSON.name = model.get("title"); - } - - // Creator - if (model.get("origin")) { - elJSON.creator = model.get("origin").map((creator) => ({ - "@type": "Person", - name: creator, - })); - } - - // Dataset/spatialCoverage - if ( - model.get("northBoundCoord") && - model.get("eastBoundCoord") && - model.get("southBoundCoord") && - model.get("westBoundCoord") - ) { - const spatialCoverage = { - "@type": "Place", - additionalProperty: [ - { - "@type": "PropertyValue", - additionalType: - "http://dbpedia.org/resource/Coordinate_reference_system", - name: "Coordinate Reference System", - value: "http://www.opengis.net/def/crs/OGC/1.3/CRS84", - }, - ], - geo: this.generateSchemaOrgGeo( - model.get("northBoundCoord"), - model.get("eastBoundCoord"), - model.get("southBoundCoord"), - model.get("westBoundCoord"), - ), - subjectOf: { - "@type": "CreativeWork", - fileFormat: "application/vnd.geo+json", - text: this.generateGeoJSONString( - model.get("northBoundCoord"), - model.get("eastBoundCoord"), - model.get("southBoundCoord"), - model.get("westBoundCoord"), - ), - }, - }; - - elJSON.spatialCoverage = spatialCoverage; - } - - // Dataset/temporalCoverage - if (model.get("beginDate") && !model.get("endDate")) { - elJSON.temporalCoverage = model.get("beginDate"); - } else if (model.get("beginDate") && model.get("endDate")) { - elJSON.temporalCoverage = `${model.get("beginDate")}/${model.get("endDate")}`; - } - - // Dataset/variableMeasured - if (model.get("attributeName")) { - elJSON.variableMeasured = model.get("attributeName"); - } - - // Dataset/description - if (model.get("abstract")) { - elJSON.description = model.get("abstract"); - } else { - const datasetsUrl = `https://dataone.org/datasets/${encodeURIComponent( - model.get("id"), - )}`; - elJSON.description = `No description is available. Visit ${datasetsUrl} for complete metadata about this dataset.`; - } - - // Dataset/keywords - if (model.get("keywords")) { - elJSON.keywords = model.get("keywords").join(", "); - } - - return elJSON; - }, - - /** - * Insert Schema.org-compliant JSONLD for the model bound to the view into - * the head tag of the page (at the end). - * @param {object} json - JSON-LD to insert into the page - * - * Some notes: - * - * - Checks if the JSONLD already exists from the previous data view - * - If not create a new script tag and append otherwise replace the text - * for the script - */ - insertJSONLD(json) { - if (!document.getElementById("jsonld")) { - const el = document.createElement("script"); - el.type = "application/ld+json"; - el.id = "jsonld"; - el.text = JSON.stringify(json); - document.querySelector("head").appendChild(el); - } else { - const script = document.getElementById("jsonld"); - script.text = JSON.stringify(json); - } - }, - - /** - * Generate a Schema.org/identifier from the model's id - * - * Tries to use the PropertyValue pattern when the identifier is a DOI and - * falls back to a Text value otherwise - * @param {string} identifier - The raw identifier - * @returns {object} - A Schema.org/PropertyValue object or a string - */ - generateSchemaOrgIdentifier(identifier) { - if (!this.model.isDOI()) { - return identifier; - } - - const doi = this.getCanonicalDOIIRI(identifier); - - if (!doi) { - return identifier; - } - - return { - "@type": "PropertyValue", - propertyID: "https://registry.identifiers.org/registry/doi", - value: doi.replace("https://doi.org/", "doi:"), - url: doi, - }; - }, - - /** - * Generate a Schema.org/Place/geo from bounding coordinates - * - * Either generates a GeoCoordinates (when the north and east coords are - * the same) or a GeoShape otherwise. - * @param {number} north - North bounding coordinate - * @param {number} east - East bounding coordinate - * @param {number} south - South bounding coordinate - * @param {number} west - West bounding coordinate - * @returns {object} - A Schema.org/Place/geo object - */ - generateSchemaOrgGeo(north, east, south, west) { - if (north === south) { - return { - "@type": "GeoCoordinates", - latitude: north, - longitude: west, - }; - } - return { - "@type": "GeoShape", - box: `${west}, ${south} ${east}, ${north}`, - }; - }, - - /** - * Creates a (hopefully) valid geoJSON string from the a set of bounding - * coordinates from the Solr index (north, east, south, west). - * - * This function produces either a GeoJSON Point or Polygon depending on - * whether the north and south bounding coordinates are the same. - * - * Part of the reason for factoring this out, in addition to code - * organization issues, is that the GeoJSON spec requires us to modify the - * raw result from Solr when the coverage crosses -180W which is common - * for datasets that cross the Pacific Ocean. In this case, We need to - * convert the east bounding coordinate from degrees west to degrees east. - * - * e.g., if the east bounding coordinate is 120 W and west bounding - * coordinate is 140 E, geoJSON requires we specify 140 E as 220 - * @param {number} north - North bounding coordinate - * @param {number} east - East bounding coordinate - * @param {number} south - South bounding coordinate - * @param {number} west - West bounding coordinate - * @returns {string} - A stringified GeoJSON object - */ - generateGeoJSONString(north, east, south, west) { - if (north === south) { - return this.generateGeoJSONPoint(north, east); - } - return this.generateGeoJSONPolygon(north, east, south, west); - }, - - /** - * Generate a GeoJSON Point object - * @param {number} north - North bounding coordinate - * @param {number} east - East bounding coordinate - * @returns {string} - A stringified GeoJSON Point object - * @example - * { - * "type": "Point", - * "coordinates": [ - * -105.01621, - * 39.57422 - * ]} - */ - generateGeoJSONPoint(north, east) { - const preamble = '{"type":"Point","coordinates":'; - const inner = `[${east},${north}]`; - const postamble = "}"; - - return preamble + inner + postamble; - }, - - /** - * Generate a GeoJSON Polygon object from - * @param {number} north - North bounding coordinate - * @param {number} east - East bounding coordinate - * @param {number} south - South bounding coordinate - * @param {number} west - West bounding coordinate - * @returns {string} - A stringified GeoJSON Polygon object - * @example - * { - * "type": "Polygon", - * "coordinates": [[ - * [ 100, 0 ], - * [ 101, 0 ], - * [ 101, 1 ], - * [ 100, 1 ], - * [ 100, 0 ] - * ]} - */ - generateGeoJSONPolygon(north, east, south, west) { - const preamble = - '{"type":"Feature","properties":{},"geometry":{"type":"Polygon","coordinates":[['; - - // Handle the case when the polygon wraps across the 180W/180E boundary - const fixedEast = east < west ? 360 - east : east; - - const inner = - `[${west},${south}],` + - `[${fixedEast},${south}],` + - `[${fixedEast},${north}],` + - `[${west},${north}],` + - `[${west},${south}]`; - - const postamble = "]]}}"; - - return preamble + inner + postamble; - }, - - /** - * Create a canonical IRI for a DOI given a random DataONE identifier. - * Useful for describing resources identified by DOIs in linked open data - * contexts or possibly also useful for comparing two DOIs for equality. - * Note: Really could be generalized to more identifier schemes. - * @param {string} identifier The identifier to (possibly) create the IRI - * for. - * @returns {string|null} Returns null when matching the identifier to a - * DOI regex fails or a string when the match is successful - */ - getCanonicalDOIIRI(identifier) { - return MetacatUI.appModel.DOItoURL(identifier) || null; - }, - /** * Insert citation information as meta tags into the head of the page * diff --git a/src/js/views/schemaOrg/SchemaOrgView.js b/src/js/views/schemaOrg/SchemaOrgView.js index d3337029a..01a8ba80a 100644 --- a/src/js/views/schemaOrg/SchemaOrgView.js +++ b/src/js/views/schemaOrg/SchemaOrgView.js @@ -1,6 +1,10 @@ "use strict"; -define(["backbone", "models/schemaOrg/SchemaOrg"], (Backbone, SchemaOrg) => { +define([ + "backbone", + "models/schemaOrg/SchemaOrg", + "text!templates/jsonld.txt", +], (Backbone, SchemaOrg, jsonLDTemplate) => { const SCRIPT_TYPE = "application/ld+json"; const JSON_LD_ID = "jsonld"; /** @@ -25,6 +29,12 @@ define(["backbone", "models/schemaOrg/SchemaOrg"], (Backbone, SchemaOrg) => { this.listenTo(this.model, "change", this.updateJsonldEl); }, + /** + * Default JSON LD to use for pages that don't have a specific schema. + * @type {string} + */ + template: jsonLDTemplate, + /** * Checks if the JSON-LD feature is enabled in the MetacatUI configuration. * @returns {boolean} True if the JSON-LD feature is enabled, false otherwise. @@ -56,12 +66,12 @@ define(["backbone", "models/schemaOrg/SchemaOrg"], (Backbone, SchemaOrg) => { /** * Sets the schema based on a template. - * @param {string} template - The template to use for the schema. This - * must be stringified JSON that follows the schema.org schema. */ - setSchemaFromTemplate(template) { + setSchemaFromTemplate() { if (!this.isEnabled()) return; - if (typeof template === "string") { + const template = this.template.trim(); + if (!template) return; + if (typeof template === "string" && template.length > 0) { this.model.setSchemaFromTemplate(template); } }, diff --git a/src/js/views/search/CatalogSearchView.js b/src/js/views/search/CatalogSearchView.js index a5168b71e..a887a9812 100644 --- a/src/js/views/search/CatalogSearchView.js +++ b/src/js/views/search/CatalogSearchView.js @@ -406,7 +406,7 @@ define([ } // Add LinkedData to the page - this.addLinkedData(); + MetacatUI.appView.schemaOrg.setSchema("DataCatalog"); // Render the template this.$el.html( @@ -659,62 +659,6 @@ define([ } }, - /** - * Linked Data Object for appending the jsonld into the browser DOM - * @since 2.22.0 - */ - addLinkedData: function () { - try { - // JSON Linked Data Object - let elJSON = { - "@context": { - "@vocab": "http://schema.org/", - }, - "@type": "DataCatalog", - }; - - // Find the MN info from the CN Node list - let members = MetacatUI.nodeModel.get("members"), - nodeModelObject; - - for (let i = 0; i < members.length; i++) { - if ( - members[i].identifier == - MetacatUI.nodeModel.get("currentMemberNode") - ) { - nodeModelObject = members[i]; - } - } - if (nodeModelObject) { - // "keywords": "", "provider": "", - let conditionalData = { - description: nodeModelObject.description, - identifier: nodeModelObject.identifier, - image: nodeModelObject.logo, - name: nodeModelObject.name, - url: nodeModelObject.url, - }; - $.extend(elJSON, conditionalData); - } - - // Check if the jsonld already exists from the previous data view If - // not create a new script tag and append otherwise replace the text - // for the script - if (!document.getElementById("jsonld")) { - var el = document.createElement("script"); - el.type = "application/ld+json"; - el.id = "jsonld"; - el.text = JSON.stringify(elJSON); - document.querySelector("head").appendChild(el); - } else { - var script = document.getElementById("jsonld"); - script.text = JSON.stringify(elJSON); - } - } catch (e) { - console.error("Couldn't add linked data to search. ", e); - } - }, - /** * Shows or hide the filters * @param {boolean} show - Optionally provide the desired choice of @@ -892,7 +836,7 @@ define([ .classList.remove(this.bodyClass, this.hideMapClass); // Remove the JSON-LD from the page - document.getElementById("jsonld")?.remove(); + MetacatUI.appView.schemaOrg.removeExistingJsonldEls(); } catch (e) { console.error("Couldn't close search view. ", e); } From b0aa7abfe48a668f3162d3956b0ea66c221ae68c Mon Sep 17 00:00:00 2001 From: robyngit Date: Wed, 23 Oct 2024 17:14:11 -0400 Subject: [PATCH 3/3] Fix syntax error in KNB JSON-LD template --- src/js/themes/knb/templates/jsonld.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/src/js/themes/knb/templates/jsonld.txt b/src/js/themes/knb/templates/jsonld.txt index 59d204a59..fcc724cae 100644 --- a/src/js/themes/knb/templates/jsonld.txt +++ b/src/js/themes/knb/templates/jsonld.txt @@ -39,4 +39,3 @@ } } } -}