Skip to content

Commit ef7bc1d

Browse files
authored
Merge pull request #152 from UC-OSPO-Network/feat/curated-created-144
feat(provenance): curated vs created badge + JSON-LD provenance (#144)
2 parents 45a56d1 + a97ba78 commit ef7bc1d

7 files changed

Lines changed: 103 additions & 5 deletions

File tree

public/styles.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1274,6 +1274,18 @@ a.lesson-badge--provider:hover {
12741274
border-color: rgba(0, 129, 85, 0.4);
12751275
}
12761276

1277+
.lesson-badge--created {
1278+
background: rgba(0, 85, 129, 0.1);
1279+
color: var(--uc-dark-blue);
1280+
border-color: rgba(0, 85, 129, 0.3);
1281+
}
1282+
1283+
.lesson-badge--curated {
1284+
background: var(--bg-surface-strong);
1285+
color: var(--text-secondary);
1286+
border-color: var(--border-light);
1287+
}
1288+
12771289
.lesson-detail-row--provider {
12781290
align-items: flex-start;
12791291
}

src/components/LessonCard.jsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const TYPE_ICONS = {
66
course: AcademicCapIcon,
77
};
88
import HealthSignalRow from "./HealthSignalRow.jsx";
9+
import { isCreatedByUs } from "../lib/provenance";
910

1011
/** @typedef {import("../lib/githubHealth").HealthRecord} HealthRecord */
1112

@@ -52,6 +53,7 @@ export default function LessonCard({ lesson, lessonIndex = {}, headingLevel = 3,
5253
const TypeIcon = TYPE_ICONS[lesson.learningResourceType?.toLowerCase()] ?? null;
5354
const roleTags = (lesson.roles ?? []).slice(0, 2);
5455
const duration = formatDuration(lesson.timeRequired);
56+
const createdByUs = isCreatedByUs(lesson);
5557

5658
const prerequisiteLinks = (lesson.prerequisites ?? [])
5759
.map((prereq) => {
@@ -98,6 +100,12 @@ export default function LessonCard({ lesson, lessonIndex = {}, headingLevel = 3,
98100
<p className="lesson-card__meta-type">
99101
{TypeIcon && <TypeIcon className="lesson-card__meta-type-icon" aria-hidden="true" />}
100102
{topMeta}
103+
<span
104+
className={`lesson-card__provenance lesson-card__provenance--${createdByUs ? "created" : "curated"}`}
105+
title={createdByUs ? "Created by the UC OSPO Network" : "An external resource curated by the UC OSPO Network"}
106+
>
107+
{createdByUs ? "Created" : "Curated"}
108+
</span>
101109
</p>
102110
{roleTags.length > 0 && (
103111
<p className="lesson-card__meta-role">{roleTags.join(", ")}</p>

src/components/LessonJsonLd.astro

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
import type { Lesson } from "../lib/lessons";
3+
import { isCreatedByUs, CREATOR_ORG } from "../lib/provenance";
34
import { getCollection } from "astro:content";
45
56
interface Props {
@@ -8,6 +9,7 @@ interface Props {
89
}
910
1011
const { lesson, canonicalUrl } = Astro.props as Props;
12+
const createdByUs = isCreatedByUs(lesson);
1113
1214
const pathways = await getCollection("pathways");
1315
const pathwayMap = Object.fromEntries(pathways.map((p) => [p.id, p.data.name]));
@@ -37,11 +39,32 @@ if (nonEmpty(lesson.license)) data.license = lesson.license;
3739
if (nonEmpty(lesson.author)) data.author = { "@type": "Person", name: lesson.author };
3840
if (nonEmpty(lesson.learningObjectives)) data.teaches = lesson.learningObjectives;
3941
42+
// Provenance: we always publish the structured data (sdPublisher). For curated
43+
// external resources, point sameAs at the canonical source and credit the
44+
// source provider as publisher; for resources we created, we are the publisher.
45+
data.sdPublisher = { "@type": "Organization", name: CREATOR_ORG };
46+
if (createdByUs) {
47+
data.publisher = { "@type": "Organization", name: CREATOR_ORG };
48+
} else {
49+
if (nonEmpty(lesson.url)) data.sameAs = lesson.url;
50+
if (nonEmpty(lesson.provider)) data.publisher = { "@type": "Organization", name: lesson.provider };
51+
}
52+
53+
// Intended educational role (schema.org): guides are for self-study (student);
54+
// taught formats serve both learners and instructors.
55+
const taught = ["workshop", "course"].includes((lesson.learningResourceType ?? "").toLowerCase());
56+
const educationalAudience = [
57+
{ "@type": "EducationalAudience", educationalRole: "student" },
58+
...(taught ? [{ "@type": "EducationalAudience", educationalRole: "teacher" }] : []),
59+
];
60+
4061
if (lesson.roles.length > 0) {
41-
data.audience = lesson.roles.map((role) => ({
42-
"@type": "Audience",
43-
audienceType: role,
44-
}));
62+
data.audience = [
63+
...educationalAudience,
64+
...lesson.roles.map((role) => ({ "@type": "Audience", audienceType: role })),
65+
];
66+
} else {
67+
data.audience = educationalAudience;
4568
}
4669
4770
if (lesson.pathways.length > 0) {

src/components/lessons/lessons.css

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,28 @@
206206
color: var(--text-secondary);
207207
}
208208

209+
.lesson-card__provenance {
210+
margin-left: auto;
211+
padding: 0.1rem 0.5rem;
212+
border-radius: var(--radius-pill);
213+
font-size: 0.62rem;
214+
font-weight: 700;
215+
letter-spacing: 0.04em;
216+
border: 1px solid transparent;
217+
}
218+
219+
.lesson-card__provenance--created {
220+
background: rgba(0, 85, 129, 0.1);
221+
color: var(--uc-dark-blue);
222+
border-color: rgba(0, 85, 129, 0.3);
223+
}
224+
225+
.lesson-card__provenance--curated {
226+
background: var(--bg-surface-strong);
227+
color: var(--text-secondary);
228+
border-color: var(--border-light);
229+
}
230+
209231
.lesson-card__meta-type-icon {
210232
width: 0.85rem;
211233
height: 0.85rem;

src/lib/lessons.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ export function formatDuration(duration: string | undefined | null): string {
4949
return [match[1] ? `${match[1]}h` : '', match[2] ? `${match[2]}m` : ''].filter(Boolean).join(' ');
5050
}
5151

52+
// Curated-vs-created lives in lib/provenance.ts (dependency-free so client
53+
// components can import it). Re-exported here for server-side convenience.
54+
export { CREATOR_ORG, isCreatedByUs } from './provenance';
55+
5256
export async function getLessons(): Promise<Lesson[]> {
5357
const entries = await getCollection('lessons');
5458
return entries.map((entry) => {

src/lib/provenance.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Curated (external resource we selected) vs. Created (we authored it).
2+
// Kept dependency-free so it can be imported by client components — importing
3+
// from lessons.ts would pull in the server-only `astro:content` module.
4+
5+
// The organization that authors the lessons we create ourselves.
6+
export const CREATOR_ORG = 'UC OSPO Network';
7+
8+
/**
9+
* True if we authored this lesson (vs. curated it from an external source).
10+
* Signal: provider is our org, or the source/repo lives in our namespace.
11+
*/
12+
export function isCreatedByUs(lesson: {
13+
provider?: string | null;
14+
repoUrl?: string | null;
15+
url?: string | null;
16+
}): boolean {
17+
if ((lesson.provider ?? '').trim() === CREATOR_ORG) return true;
18+
const haystack = `${lesson.repoUrl ?? ''} ${lesson.url ?? ''}`.toLowerCase();
19+
return haystack.includes('uc-ospo-network') || haystack.includes('ucospo.net');
20+
}

src/pages/lessons/[slug].astro

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import BaseLayout from "../../layouts/BaseLayout.astro";
33
import LessonJsonLd from "../../components/LessonJsonLd.astro";
44
import Citation from "../../components/Citation.astro";
5-
import { getLessons, getRelatedLessons, type Lesson, formatDuration } from "../../lib/lessons";
5+
import { getLessons, getRelatedLessons, type Lesson, formatDuration, isCreatedByUs } from "../../lib/lessons";
66
import { lookupProvider } from "../../lib/providers";
77
import { normalizeCitation } from "../../lib/citation";
88
import { getCollection } from "astro:content";
@@ -142,6 +142,15 @@ const hasPrereqs =
142142
</nav>
143143

144144
<div class="lesson-badges">
145+
{isCreatedByUs(lesson) ? (
146+
<span class="lesson-badge lesson-badge--created" title="Created by the UC OSPO Network">
147+
Created by us
148+
</span>
149+
) : (
150+
<span class="lesson-badge lesson-badge--curated" title="An external resource curated by the UC OSPO Network">
151+
Curated · external resource
152+
</span>
153+
)}
145154
{pathwayName && (
146155
<span class="lesson-badge lesson-badge--pathway">
147156
{pathwayName}

0 commit comments

Comments
 (0)