diff --git a/website/src/layouts/BaseLayout.astro b/website/src/layouts/BaseLayout.astro
index 47201548df..fb0667be85 100644
--- a/website/src/layouts/BaseLayout.astro
+++ b/website/src/layouts/BaseLayout.astro
@@ -60,6 +60,9 @@ const {
+
+
+
diff --git a/website/src/pages/workflow/[id].astro b/website/src/pages/workflow/[id].astro
index 02fa224c17..f027998969 100644
--- a/website/src/pages/workflow/[id].astro
+++ b/website/src/pages/workflow/[id].astro
@@ -3,6 +3,7 @@ import BaseLayout from "../../layouts/BaseLayout.astro";
import IWCHeader from "../../components/IWCHeader.astro";
import WorkflowContent from "../../components/WorkflowContent.vue";
import { loadManifest, getAllWorkflows } from "../../utils/manifest";
+import { generateWorkflowJsonLd, serializeJsonLd, generateCitationMetaTags } from "../../utils/jsonld";
import type { Workflow } from "../../models/workflow";
export async function getStaticPaths() {
@@ -22,6 +23,13 @@ const { workflow } = Astro.props as { workflow: Workflow };
const baseUrl = "https://iwc.galaxyproject.org";
const workflowName = workflow.definition?.name || "Workflow Details";
const description = workflow.definition.annotation || "Galaxy workflow";
+
+// Generate JSON-LD structured data for SEO and academic discoverability
+const jsonLd = generateWorkflowJsonLd(workflow, baseUrl);
+const jsonLdScript = serializeJsonLd(jsonLd);
+
+// Generate Google Scholar citation meta tags
+const citationMetaTags = generateCitationMetaTags(workflow);
---
+
+
+
+
+
+
+ {citationMetaTags.map((tag) => )}
+
+
diff --git a/website/src/utils/jsonld.ts b/website/src/utils/jsonld.ts
new file mode 100644
index 0000000000..4a07184ecd
--- /dev/null
+++ b/website/src/utils/jsonld.ts
@@ -0,0 +1,187 @@
+import type { Workflow } from "../models/workflow";
+
+/**
+ * Schema.org Person or Organization type
+ */
+interface SchemaOrgEntity {
+ "@type": "Person" | "Organization";
+ name: string;
+ identifier?: string;
+ email?: string;
+ url?: string;
+}
+
+/**
+ * Schema.org JSON-LD structured data for a workflow
+ */
+interface WorkflowJsonLd {
+ "@context": "https://schema.org";
+ "@type": string[];
+ name: string;
+ description: string;
+ version?: string;
+ license?: string;
+ identifier?: string;
+ url: string;
+ dateModified: string;
+ creator: SchemaOrgEntity[];
+ keywords: string[];
+ programmingLanguage: {
+ "@type": "ComputerLanguage";
+ name: string;
+ url: string;
+ };
+}
+
+/**
+ * Generates Schema.org JSON-LD structured data for a workflow
+ * This helps with SEO and discoverability in academic search engines like Google Scholar
+ *
+ * @param workflow - The workflow object from the manifest
+ * @param baseUrl - Base URL for the IWC website (e.g., "https://iwc.galaxyproject.org")
+ * @returns JSON-LD object ready to be serialized
+ */
+export function generateWorkflowJsonLd(
+ workflow: Workflow,
+ baseUrl: string = "https://iwc.galaxyproject.org",
+): WorkflowJsonLd {
+ // Build Dockstore URL from TRS ID
+ // TRS ID format: "#workflow/github.com/iwc-workflows/[name]/[version]"
+ const dockstoreUrl = workflow.trsID
+ ? `https://dockstore.org/workflows/${workflow.trsID.replace("#workflow/", "")}`
+ : `${baseUrl}/workflow/${workflow.iwcID}`;
+
+ // Map creators to Schema.org Person or Organization
+ const creators: SchemaOrgEntity[] = workflow.definition.creator.map((creator) => {
+ const entity: SchemaOrgEntity = {
+ "@type": creator.class === "Person" ? "Person" : "Organization",
+ name: creator.name,
+ };
+
+ // Add ORCID identifier if available
+ if (creator.identifier) {
+ entity.identifier = creator.identifier;
+ }
+
+ // Add URL if available
+ if (creator.url) {
+ entity.url = creator.url;
+ }
+
+ return entity;
+ });
+
+ // Combine collections and tags for keywords
+ const keywords: string[] = [...(workflow.collections || []), ...(workflow.definition.tags || [])].filter(Boolean);
+
+ const jsonLd: WorkflowJsonLd = {
+ "@context": "https://schema.org",
+ "@type": ["SoftwareSourceCode", "ComputationalWorkflow"],
+ name: workflow.definition.name,
+ description: workflow.definition.annotation || "",
+ url: dockstoreUrl,
+ dateModified: workflow.updated,
+ creator: creators,
+ keywords,
+ programmingLanguage: {
+ "@type": "ComputerLanguage",
+ name: "Galaxy",
+ url: "https://galaxyproject.org",
+ },
+ };
+
+ // Add optional fields if they exist
+ if (workflow.definition.release) {
+ jsonLd.version = workflow.definition.release;
+ }
+
+ if (workflow.definition.license) {
+ jsonLd.license = workflow.definition.license;
+ }
+
+ if (workflow.doi) {
+ // Use DOI as the identifier for better academic discoverability
+ jsonLd.identifier = `https://doi.org/${workflow.doi}`;
+ }
+
+ return jsonLd;
+}
+
+/**
+ * Serializes JSON-LD object to a string ready for injection into HTML
+ *
+ * @param jsonLd - The JSON-LD object
+ * @returns Stringified JSON-LD
+ */
+export function serializeJsonLd(jsonLd: WorkflowJsonLd): string {
+ return JSON.stringify(jsonLd, null, 2);
+}
+
+/**
+ * Google Scholar citation meta tag
+ */
+export interface CitationMetaTag {
+ name: string;
+ content: string;
+}
+
+/**
+ * Generates Google Scholar citation meta tags for a workflow
+ * Google Scholar requires: title, author(s), and publication_date
+ *
+ * @param workflow - The workflow object from the manifest
+ * @returns Array of meta tag objects with name and content
+ */
+export function generateCitationMetaTags(workflow: Workflow): CitationMetaTag[] {
+ const tags: CitationMetaTag[] = [];
+
+ // Required: Title
+ tags.push({
+ name: "citation_title",
+ content: workflow.definition.name,
+ });
+
+ // Required: Authors (one tag per person, skip organizations)
+ workflow.definition.creator.forEach((creator) => {
+ if (creator.class === "Person") {
+ tags.push({
+ name: "citation_author",
+ content: creator.name,
+ });
+ }
+ });
+
+ // Required: Publication date
+ const publicationDate = new Date(workflow.updated).toISOString().split("T")[0];
+ tags.push({
+ name: "citation_publication_date",
+ content: publicationDate,
+ });
+
+ // Optional: DOI
+ if (workflow.doi) {
+ tags.push({
+ name: "citation_doi",
+ content: workflow.doi,
+ });
+ }
+
+ // Optional: Abstract
+ if (workflow.definition.annotation) {
+ tags.push({
+ name: "citation_abstract",
+ content: workflow.definition.annotation,
+ });
+ }
+
+ // Optional: Keywords
+ const keywords = [...(workflow.collections || []), ...(workflow.definition.tags || [])].filter(Boolean);
+ if (keywords.length > 0) {
+ tags.push({
+ name: "citation_keywords",
+ content: keywords.join("; "),
+ });
+ }
+
+ return tags;
+}
diff --git a/website/tests/e2e/jsonld.spec.js b/website/tests/e2e/jsonld.spec.js
new file mode 100644
index 0000000000..4722a7ba7c
--- /dev/null
+++ b/website/tests/e2e/jsonld.spec.js
@@ -0,0 +1,175 @@
+import { test, expect } from "@playwright/test";
+
+test.describe("Structured metadata for academic search", () => {
+ test("Workflow page includes valid JSON-LD", async ({ page }) => {
+ // Navigate to a workflow page
+ await page.goto("/workflow/rnaseq-sr-main/");
+
+ // Extract JSON-LD from the page
+ const jsonLdScript = await page.locator('script[type="application/ld+json"]').textContent();
+
+ // Verify JSON-LD exists
+ expect(jsonLdScript).toBeTruthy();
+
+ // Parse JSON-LD
+ const jsonLd = JSON.parse(jsonLdScript);
+
+ // Verify Schema.org context
+ expect(jsonLd["@context"]).toBe("https://schema.org");
+
+ // Verify type includes both SoftwareSourceCode and ComputationalWorkflow
+ expect(jsonLd["@type"]).toEqual(expect.arrayContaining(["SoftwareSourceCode", "ComputationalWorkflow"]));
+
+ // Verify required fields exist
+ expect(jsonLd.name).toBeTruthy();
+ expect(jsonLd.description).toBeTruthy();
+ expect(jsonLd.url).toBeTruthy();
+ expect(jsonLd.dateModified).toBeTruthy();
+ expect(jsonLd.creator).toBeDefined();
+ expect(jsonLd.keywords).toBeDefined();
+ expect(jsonLd.programmingLanguage).toBeDefined();
+
+ // Verify creator is an array
+ expect(Array.isArray(jsonLd.creator)).toBe(true);
+ expect(jsonLd.creator.length).toBeGreaterThan(0);
+
+ // Verify first creator has required fields
+ const firstCreator = jsonLd.creator[0];
+ expect(firstCreator["@type"]).toMatch(/^(Person|Organization)$/);
+ expect(firstCreator.name).toBeTruthy();
+
+ // Verify keywords is an array
+ expect(Array.isArray(jsonLd.keywords)).toBe(true);
+
+ // Verify programmingLanguage structure
+ expect(jsonLd.programmingLanguage["@type"]).toBe("ComputerLanguage");
+ expect(jsonLd.programmingLanguage.name).toBe("Galaxy");
+ expect(jsonLd.programmingLanguage.url).toBe("https://galaxyproject.org");
+
+ // Verify URL points to Dockstore
+ expect(jsonLd.url).toContain("dockstore.org/workflows");
+ });
+
+ test("Workflow with DOI includes identifier field", async ({ page }) => {
+ await page.goto("/workflow/chipseq-sr-main/");
+
+ const jsonLdScript = await page.locator('script[type="application/ld+json"]').textContent();
+ const jsonLd = JSON.parse(jsonLdScript);
+
+ // Verify DOI identifier exists and is properly formatted
+ expect(jsonLd.identifier).toBeTruthy();
+ expect(jsonLd.identifier).toMatch(/^https:\/\/doi\.org\/10\.\d+\/zenodo\.\d+$/);
+ });
+
+ test("Workflow with ORCID includes creator identifiers", async ({ page }) => {
+ await page.goto("/workflow/rnaseq-sr-main/");
+
+ const jsonLdScript = await page.locator('script[type="application/ld+json"]').textContent();
+ const jsonLd = JSON.parse(jsonLdScript);
+
+ // Find a creator with ORCID (if exists)
+ const creatorWithOrcid = jsonLd.creator.find((c) => c.identifier);
+
+ if (creatorWithOrcid) {
+ expect(creatorWithOrcid.identifier).toMatch(/^https:\/\/orcid\.org\/\d{4}-\d{4}-\d{4}-\d{4}$/);
+ }
+ });
+
+ test("Workflow with version includes version field", async ({ page }) => {
+ await page.goto("/workflow/bacterial-genome-assembly-main/");
+
+ const jsonLdScript = await page.locator('script[type="application/ld+json"]').textContent();
+ const jsonLd = JSON.parse(jsonLdScript);
+
+ // Verify version field exists
+ expect(jsonLd.version).toBeDefined();
+ expect(typeof jsonLd.version).toBe("string");
+ });
+
+ test("Workflow with license includes license field", async ({ page }) => {
+ await page.goto("/workflow/chipseq-sr-main/");
+
+ const jsonLdScript = await page.locator('script[type="application/ld+json"]').textContent();
+ const jsonLd = JSON.parse(jsonLdScript);
+
+ // Verify license field exists
+ expect(jsonLd.license).toBeDefined();
+ expect(typeof jsonLd.license).toBe("string");
+ expect(jsonLd.license.length).toBeGreaterThan(0);
+ });
+
+ test("Multiple workflow pages have unique JSON-LD", async ({ page }) => {
+ // Test first workflow
+ await page.goto("/workflow/rnaseq-sr-main/");
+ const jsonLd1Script = await page.locator('script[type="application/ld+json"]').textContent();
+ const jsonLd1 = JSON.parse(jsonLd1Script);
+
+ // Test second workflow
+ await page.goto("/workflow/chipseq-sr-main/");
+ const jsonLd2Script = await page.locator('script[type="application/ld+json"]').textContent();
+ const jsonLd2 = JSON.parse(jsonLd2Script);
+
+ // Verify they have different names and URLs
+ expect(jsonLd1.name).not.toBe(jsonLd2.name);
+ expect(jsonLd1.url).not.toBe(jsonLd2.url);
+ expect(jsonLd1.identifier).not.toBe(jsonLd2.identifier);
+ });
+});
+
+test.describe("Google Scholar citation meta tags", () => {
+ test("Workflow page includes required citation meta tags", async ({ page }) => {
+ await page.goto("/workflow/rnaseq-sr-main/");
+
+ // Verify required meta tags exist
+ const titleTag = await page.locator('meta[name="citation_title"]').getAttribute("content");
+ expect(titleTag).toBeTruthy();
+ expect(titleTag).toContain("RNA-Seq");
+
+ // Verify at least one author tag
+ const authorTags = await page.locator('meta[name="citation_author"]').count();
+ expect(authorTags).toBeGreaterThan(0);
+
+ // Verify publication date
+ const dateTag = await page.locator('meta[name="citation_publication_date"]').getAttribute("content");
+ expect(dateTag).toBeTruthy();
+ expect(dateTag).toMatch(/^\d{4}-\d{2}-\d{2}$/); // YYYY-MM-DD format
+ });
+
+ test("Workflow with DOI includes citation_doi meta tag", async ({ page }) => {
+ await page.goto("/workflow/chipseq-sr-main/");
+
+ const doiTag = await page.locator('meta[name="citation_doi"]').getAttribute("content");
+ expect(doiTag).toBeTruthy();
+ expect(doiTag).toMatch(/^10\.\d+\/zenodo\.\d+$/);
+ });
+
+ test("Workflow includes citation_keywords", async ({ page }) => {
+ await page.goto("/workflow/rnaseq-sr-main/");
+
+ const keywordsTag = await page.locator('meta[name="citation_keywords"]').getAttribute("content");
+ expect(keywordsTag).toBeTruthy();
+ expect(keywordsTag).toContain(";"); // Keywords should be semicolon-separated
+ });
+
+ test("Workflow includes citation_abstract", async ({ page }) => {
+ await page.goto("/workflow/chipseq-sr-main/");
+
+ const abstractTag = await page.locator('meta[name="citation_abstract"]').getAttribute("content");
+ expect(abstractTag).toBeTruthy();
+ expect(abstractTag.length).toBeGreaterThan(20);
+ });
+
+ test("Only person creators are listed as citation_author", async ({ page }) => {
+ await page.goto("/workflow/mgnify-amplicon-pipeline-v5-quality-control-single-end-main/");
+
+ // This workflow has both Organization and Person creators
+ const authorTags = await page.locator('meta[name="citation_author"]').all();
+ const authors = await Promise.all(authorTags.map((tag) => tag.getAttribute("content")));
+
+ // Verify we have person authors (should be 2: Rand Zoabi and Paul Zierep)
+ expect(authors.length).toBeGreaterThan(0);
+
+ // Verify none of the authors are "MGnify - EMBL" (the organization)
+ expect(authors).not.toContain("MGnify - EMBL");
+ });
+});