feat: add Educational Resource (kind 30142) with AMB metadata support (#260)

* feat: add Educational Resource (kind 30142) with AMB metadata support

- Add feed and detail renderers for AMB Educational Resource events
- Add amb-helpers library with cached helper functions and tests
- Handle broken thumbnail images with BookOpen placeholder fallback
- Surface primary resource URL prominently in both renderers (bookmark-style)
- Register kind 30142 with GraduationCap icon in kind registry
- Link NIP-AMB badge to community NIP event (kind 30817)

* fix: address PR #260 review comments for Educational Resource renderers

- Rename Kind30142*Renderer to EducationalResource*Renderer (human-friendly names)
- Localize language names with Intl.DisplayNames via shared locale-utils
- Use ExternalLink component for license and reference URLs
- Localize ISO dates with formatISODate, fixing UTC timezone shift bug
- Remove # prefix from keyword labels in both feed and detail renderers
- Remove image/thumbnail from feed renderer
- Extract getBrowserLanguage to shared locale-utils, reuse in amb-helpers

* fix: mock getBrowserLanguage in tests for Node < 21 compat

Tests were directly mutating navigator.language which doesn't exist
as a global in Node < 21, causing ReferenceError in CI.
This commit is contained in:
Steffen Rörtgen
2026-03-16 09:34:29 +01:00
committed by GitHub
parent 8e11139c5d
commit 5619122b80
9 changed files with 1202 additions and 1 deletions

363
src/lib/amb-helpers.test.ts Normal file
View File

@@ -0,0 +1,363 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { NostrEvent } from "@/types/nostr";
import {
getAmbCreators,
getAmbRelatedResources,
getAmbIsAccessibleForFree,
getAmbDescription,
} from "./amb-helpers";
// Mock locale-utils so we can control getBrowserLanguage without relying on navigator
vi.mock("@/lib/locale-utils", () => ({
getBrowserLanguage: vi.fn(() => "en"),
}));
import { getBrowserLanguage } from "@/lib/locale-utils";
const mockGetBrowserLanguage = vi.mocked(getBrowserLanguage);
// Helper to build a minimal event with specific tags
function makeEvent(tags: string[][], content = ""): NostrEvent {
return {
id: "test",
pubkey: "test",
created_at: 0,
kind: 30142,
tags,
content,
sig: "test",
};
}
describe("amb-helpers", () => {
beforeEach(() => {
mockGetBrowserLanguage.mockReturnValue("en");
});
describe("findPrefLabel (via getAmbLearningResourceType)", () => {
it("should return browser language label when available", async () => {
mockGetBrowserLanguage.mockReturnValue("de");
const { getAmbLearningResourceType } = await import("./amb-helpers.ts");
const event = makeEvent([
["learningResourceType:id", "https://example.org/type/1"],
["learningResourceType:prefLabel:en", "Course"],
["learningResourceType:prefLabel:de", "Kurs"],
]);
const result = getAmbLearningResourceType(event);
expect(result?.label).toBe("Kurs");
});
it("should fall back to English when browser language not available", async () => {
mockGetBrowserLanguage.mockReturnValue("fr");
const { getAmbEducationalLevel } = await import("./amb-helpers.ts");
const event = makeEvent([
["educationalLevel:id", "https://example.org/level/1"],
["educationalLevel:prefLabel:de", "Grundschule"],
["educationalLevel:prefLabel:en", "Primary School"],
]);
const result = getAmbEducationalLevel(event);
expect(result?.label).toBe("Primary School");
});
it("should fall back to first available when neither browser lang nor en exists", async () => {
mockGetBrowserLanguage.mockReturnValue("ja");
const { getAmbAudience } = await import("./amb-helpers.ts");
const event = makeEvent([
["audience:id", "https://example.org/audience/1"],
["audience:prefLabel:de", "Lernende"],
["audience:prefLabel:es", "Estudiantes"],
]);
const result = getAmbAudience(event);
expect(result?.label).toBe("Lernende");
});
it("should return undefined when no labels exist", async () => {
mockGetBrowserLanguage.mockReturnValue("en");
const { getAmbLearningResourceType } = await import("./amb-helpers.ts");
const event = makeEvent([
["learningResourceType:id", "https://example.org/type/1"],
]);
const result = getAmbLearningResourceType(event);
expect(result?.label).toBeUndefined();
});
});
describe("getAmbSubjects", () => {
it("should pair ids with labels from preferred language only", async () => {
mockGetBrowserLanguage.mockReturnValue("de");
const { getAmbSubjects } = await import("./amb-helpers.ts");
const event = makeEvent([
["about:id", "https://example.org/subject/math"],
["about:id", "https://example.org/subject/physics"],
["about:prefLabel:de", "Mathematik"],
["about:prefLabel:de", "Physik"],
["about:prefLabel:en", "Mathematics"],
["about:prefLabel:en", "Physics"],
]);
const subjects = getAmbSubjects(event);
expect(subjects).toHaveLength(2);
expect(subjects[0]).toEqual({
id: "https://example.org/subject/math",
label: "Mathematik",
});
expect(subjects[1]).toEqual({
id: "https://example.org/subject/physics",
label: "Physik",
});
});
it("should fall back to English labels", async () => {
mockGetBrowserLanguage.mockReturnValue("ja");
const { getAmbSubjects } = await import("./amb-helpers.ts");
const event = makeEvent([
["about:id", "https://example.org/subject/math"],
["about:prefLabel:de", "Mathematik"],
["about:prefLabel:en", "Mathematics"],
]);
const subjects = getAmbSubjects(event);
expect(subjects).toHaveLength(1);
expect(subjects[0].label).toBe("Mathematics");
});
it("should handle single language correctly", async () => {
mockGetBrowserLanguage.mockReturnValue("en");
const { getAmbSubjects } = await import("./amb-helpers.ts");
const event = makeEvent([
["about:id", "https://example.org/subject/art"],
["about:prefLabel:de", "Kunst"],
]);
const subjects = getAmbSubjects(event);
expect(subjects).toHaveLength(1);
expect(subjects[0].label).toBe("Kunst");
});
it("should handle no labels gracefully", async () => {
mockGetBrowserLanguage.mockReturnValue("en");
const { getAmbSubjects } = await import("./amb-helpers.ts");
const event = makeEvent([
["about:id", "https://example.org/subject/math"],
["about:id", "https://example.org/subject/physics"],
]);
const subjects = getAmbSubjects(event);
expect(subjects).toHaveLength(2);
expect(subjects[0]).toEqual({
id: "https://example.org/subject/math",
label: undefined,
});
});
it("should handle more labels than ids", async () => {
mockGetBrowserLanguage.mockReturnValue("en");
const { getAmbSubjects } = await import("./amb-helpers.ts");
const event = makeEvent([
["about:id", "https://example.org/subject/math"],
["about:prefLabel:en", "Mathematics"],
["about:prefLabel:en", "Physics"],
]);
const subjects = getAmbSubjects(event);
expect(subjects).toHaveLength(2);
expect(subjects[1]).toEqual({
id: undefined,
label: "Physics",
});
});
});
describe("getAmbCreators", () => {
it("should extract a p-tag only creator", () => {
const event = makeEvent([
["p", "abc123", "wss://relay.example.com", "creator"],
]);
const creators = getAmbCreators(event);
expect(creators).toEqual([
{ pubkey: "abc123", relayHint: "wss://relay.example.com" },
]);
});
it("should extract a flattened-tag only creator", () => {
const event = makeEvent([
["creator:name", "Alice"],
["creator:type", "Person"],
["creator:affiliation:name", "MIT"],
["creator:id", "https://orcid.org/0000-0001"],
]);
const creators = getAmbCreators(event);
expect(creators).toEqual([
{
name: "Alice",
type: "Person",
affiliationName: "MIT",
id: "https://orcid.org/0000-0001",
},
]);
});
it("should merge flattened tags into the first p-tag creator", () => {
const event = makeEvent([
["p", "abc123", "wss://relay.example.com", "creator"],
["creator:name", "Alice"],
["creator:type", "Person"],
]);
const creators = getAmbCreators(event);
expect(creators).toHaveLength(1);
expect(creators[0]).toEqual({
pubkey: "abc123",
relayHint: "wss://relay.example.com",
name: "Alice",
type: "Person",
affiliationName: undefined,
id: undefined,
});
});
it("should only merge into the first p-tag creator", () => {
const event = makeEvent([
["p", "abc123", "", "creator"],
["p", "def456", "wss://relay2.example.com", "creator"],
["creator:name", "Alice"],
]);
const creators = getAmbCreators(event);
expect(creators).toHaveLength(2);
// First creator gets the merged name
expect(creators[0].pubkey).toBe("abc123");
expect(creators[0].name).toBe("Alice");
// Second creator has no name
expect(creators[1].pubkey).toBe("def456");
expect(creators[1].name).toBeUndefined();
});
it("should ignore p tags without creator role", () => {
const event = makeEvent([
["p", "abc123", "", "mention"],
["p", "def456"],
]);
const creators = getAmbCreators(event);
expect(creators).toEqual([]);
});
it("should handle p-tag with empty relay hint", () => {
const event = makeEvent([["p", "abc123", "", "creator"]]);
const creators = getAmbCreators(event);
expect(creators).toEqual([{ pubkey: "abc123", relayHint: undefined }]);
});
it("should return empty array for event with no creator tags", () => {
const event = makeEvent([
["t", "education"],
["type", "LearningResource"],
]);
const creators = getAmbCreators(event);
expect(creators).toEqual([]);
});
});
describe("getAmbRelatedResources", () => {
it("should extract a related resource with all fields", () => {
const event = makeEvent([
["a", "30142:pubkey123:d-tag", "wss://relay.example.com", "isPartOf"],
]);
const resources = getAmbRelatedResources(event);
expect(resources).toEqual([
{
address: "30142:pubkey123:d-tag",
relayHint: "wss://relay.example.com",
relationship: "isPartOf",
},
]);
});
it("should exclude non-30142 a tags", () => {
const event = makeEvent([
["a", "30142:pubkey123:d-tag", "", "isPartOf"],
["a", "30023:pubkey456:d-tag", "wss://relay.example.com"],
["a", "10002:pubkey789:"],
]);
const resources = getAmbRelatedResources(event);
expect(resources).toHaveLength(1);
expect(resources[0].address).toBe("30142:pubkey123:d-tag");
});
it("should handle missing relay hint and relationship", () => {
const event = makeEvent([["a", "30142:pubkey123:d-tag"]]);
const resources = getAmbRelatedResources(event);
expect(resources).toEqual([
{
address: "30142:pubkey123:d-tag",
relayHint: undefined,
relationship: undefined,
},
]);
});
it("should return empty array when no a tags exist", () => {
const event = makeEvent([["t", "education"]]);
const resources = getAmbRelatedResources(event);
expect(resources).toEqual([]);
});
});
describe("getAmbIsAccessibleForFree", () => {
it("should return true for 'true'", () => {
const event = makeEvent([["isAccessibleForFree", "true"]]);
expect(getAmbIsAccessibleForFree(event)).toBe(true);
});
it("should return false for 'false'", () => {
const event = makeEvent([["isAccessibleForFree", "false"]]);
expect(getAmbIsAccessibleForFree(event)).toBe(false);
});
it("should return undefined when tag is missing", () => {
const event = makeEvent([["t", "education"]]);
expect(getAmbIsAccessibleForFree(event)).toBeUndefined();
});
});
describe("getAmbDescription", () => {
it("should use event.content when present", () => {
const event = makeEvent(
[["description", "tag description"]],
"content description",
);
expect(getAmbDescription(event)).toBe("content description");
});
it("should fall back to description tag when content is empty", () => {
const event = makeEvent([["description", "tag description"]], "");
expect(getAmbDescription(event)).toBe("tag description");
});
it("should return undefined when both content and tag are missing", () => {
const event = makeEvent([["t", "education"]], "");
expect(getAmbDescription(event)).toBeUndefined();
});
});
});

280
src/lib/amb-helpers.ts Normal file
View File

@@ -0,0 +1,280 @@
import type { NostrEvent } from "@/types/nostr";
import { getTagValue, getOrComputeCachedValue } from "applesauce-core/helpers";
import { getBrowserLanguage } from "@/lib/locale-utils";
/**
* AMB (Allgemeines Metadatenprofil für Bildungsressourcen) Helpers
* Extract metadata from kind 30142 educational resource events
*
* Uses flattened tag convention: nested JSON-LD properties are
* represented as colon-delimited tag names (e.g., "creator:name").
*
* All cached helpers use getOrComputeCachedValue to avoid
* recomputation — no useMemo needed in components.
*/
// Cache symbols
const TypesSymbol = Symbol("ambTypes");
const KeywordsSymbol = Symbol("ambKeywords");
const CreatorsSymbol = Symbol("ambCreators");
const LearningResourceTypeSymbol = Symbol("ambLearningResourceType");
const EducationalLevelSymbol = Symbol("ambEducationalLevel");
const SubjectsSymbol = Symbol("ambSubjects");
const ExternalUrlsSymbol = Symbol("ambExternalUrls");
const RelatedResourcesSymbol = Symbol("ambRelatedResources");
const AudienceSymbol = Symbol("ambAudience");
// ============================================================================
// Simple helpers (direct tag reads)
// ============================================================================
export function getAmbName(event: NostrEvent): string | undefined {
return getTagValue(event, "name");
}
export function getAmbImage(event: NostrEvent): string | undefined {
return getTagValue(event, "image");
}
export function getAmbDescription(event: NostrEvent): string | undefined {
return event.content || getTagValue(event, "description");
}
export function getAmbLanguage(event: NostrEvent): string | undefined {
return getTagValue(event, "inLanguage");
}
export function getAmbLicenseId(event: NostrEvent): string | undefined {
return getTagValue(event, "license:id");
}
export function getAmbIsAccessibleForFree(
event: NostrEvent,
): boolean | undefined {
const val = getTagValue(event, "isAccessibleForFree");
if (val === "true") return true;
if (val === "false") return false;
return undefined;
}
export function getAmbDateCreated(event: NostrEvent): string | undefined {
return getTagValue(event, "dateCreated");
}
export function getAmbDatePublished(event: NostrEvent): string | undefined {
return getTagValue(event, "datePublished");
}
// ============================================================================
// Cached helpers (iterate tags)
// ============================================================================
/** All `type` tag values (e.g., ["LearningResource", "Course"]) */
export function getAmbTypes(event: NostrEvent): string[] {
return getOrComputeCachedValue(event, TypesSymbol, () =>
event.tags.filter((t) => t[0] === "type" && t[1]).map((t) => t[1]),
);
}
/** All `t` tag values (keywords) */
export function getAmbKeywords(event: NostrEvent): string[] {
return getOrComputeCachedValue(event, KeywordsSymbol, () =>
event.tags.filter((t) => t[0] === "t" && t[1]).map((t) => t[1]),
);
}
export interface AmbCreator {
pubkey?: string;
relayHint?: string;
name?: string;
type?: string;
affiliationName?: string;
id?: string;
}
/**
* Extract creators from both `p` tags with "creator" role
* and `creator:*` flattened tags.
*/
export function getAmbCreators(event: NostrEvent): AmbCreator[] {
return getOrComputeCachedValue(event, CreatorsSymbol, () => {
const creators: AmbCreator[] = [];
// Nostr-native creators from p tags with "creator" role
for (const tag of event.tags) {
if (tag[0] === "p" && tag[3] === "creator" && tag[1]) {
creators.push({
pubkey: tag[1],
relayHint: tag[2] || undefined,
});
}
}
// External creators from flattened tags
const creatorName = getTagValue(event, "creator:name");
const creatorType = getTagValue(event, "creator:type");
const creatorAffiliation = getTagValue(event, "creator:affiliation:name");
const creatorId = getTagValue(event, "creator:id");
if (creatorName || creatorType || creatorAffiliation || creatorId) {
// Merge with the first p-tag creator if it exists, otherwise create new
const existingNostr = creators.find((c) => c.pubkey);
if (existingNostr) {
existingNostr.name = creatorName;
existingNostr.type = creatorType;
existingNostr.affiliationName = creatorAffiliation;
existingNostr.id = creatorId;
} else {
creators.push({
name: creatorName,
type: creatorType,
affiliationName: creatorAffiliation,
id: creatorId,
});
}
}
return creators;
});
}
export interface AmbConceptRef {
id?: string;
label?: string;
}
/** learningResourceType from flattened tags */
export function getAmbLearningResourceType(
event: NostrEvent,
): AmbConceptRef | undefined {
return getOrComputeCachedValue(event, LearningResourceTypeSymbol, () => {
const id = getTagValue(event, "learningResourceType:id");
const label = findPrefLabel(event, "learningResourceType");
if (!id && !label) return undefined;
return { id, label };
});
}
/** educationalLevel from flattened tags */
export function getAmbEducationalLevel(
event: NostrEvent,
): AmbConceptRef | undefined {
return getOrComputeCachedValue(event, EducationalLevelSymbol, () => {
const id = getTagValue(event, "educationalLevel:id");
const label = findPrefLabel(event, "educationalLevel");
if (!id && !label) return undefined;
return { id, label };
});
}
/** audience from flattened tags */
export function getAmbAudience(event: NostrEvent): AmbConceptRef | undefined {
return getOrComputeCachedValue(event, AudienceSymbol, () => {
const id = getTagValue(event, "audience:id");
const label = findPrefLabel(event, "audience");
if (!id && !label) return undefined;
return { id, label };
});
}
/** All about:* subjects as concept references */
export function getAmbSubjects(event: NostrEvent): AmbConceptRef[] {
return getOrComputeCachedValue(event, SubjectsSymbol, () => {
const ids: string[] = [];
// Group labels by language: Map<lang, labels[]>
const labelsByLang = new Map<string, string[]>();
for (const tag of event.tags) {
if (tag[0] === "about:id" && tag[1]) {
ids.push(tag[1]);
}
if (tag[0]?.startsWith("about:prefLabel:") && tag[1]) {
const lang = tag[0].slice("about:prefLabel:".length);
let arr = labelsByLang.get(lang);
if (!arr) {
arr = [];
labelsByLang.set(lang, arr);
}
arr.push(tag[1]);
}
}
// Pick best language set: browser lang > "en" > first available
const browserLang = getBrowserLanguage();
const labels =
labelsByLang.get(browserLang) ??
labelsByLang.get("en") ??
labelsByLang.values().next().value ??
[];
// Pair ids with labels positionally
const count = Math.max(ids.length, labels.length);
const subjects: AmbConceptRef[] = [];
for (let i = 0; i < count; i++) {
subjects.push({
id: ids[i],
label: labels[i],
});
}
return subjects;
});
}
/** All `r` tag values (external URLs) */
export function getAmbExternalUrls(event: NostrEvent): string[] {
return getOrComputeCachedValue(event, ExternalUrlsSymbol, () =>
event.tags.filter((t) => t[0] === "r" && t[1]).map((t) => t[1]),
);
}
export interface AmbRelatedResource {
address: string;
relayHint?: string;
relationship?: string;
}
/** `a` tags pointing to other 30142 events with relationship type */
export function getAmbRelatedResources(
event: NostrEvent,
): AmbRelatedResource[] {
return getOrComputeCachedValue(event, RelatedResourcesSymbol, () =>
event.tags
.filter((t) => t[0] === "a" && t[1]?.startsWith("30142:"))
.map((t) => ({
address: t[1],
relayHint: t[2] || undefined,
relationship: t[3] || undefined,
})),
);
}
// ============================================================================
// Internal utilities
// ============================================================================
/**
* Find the best prefLabel for a given prefix using locale fallback:
* browser language > "en" > first available
*/
function findPrefLabel(event: NostrEvent, prefix: string): string | undefined {
const labelPrefix = `${prefix}:prefLabel:`;
const browserLang = getBrowserLanguage();
let browserMatch: string | undefined;
let enMatch: string | undefined;
let firstMatch: string | undefined;
for (const tag of event.tags) {
if (tag[0]?.startsWith(labelPrefix) && tag[1]) {
const lang = tag[0].slice(labelPrefix.length);
if (!firstMatch) firstMatch = tag[1];
if (lang === browserLang && !browserMatch) browserMatch = tag[1];
if (lang === "en" && !enMatch) enMatch = tag[1];
// Early exit if we found the best match
if (browserMatch) break;
}
}
return browserMatch ?? enMatch ?? firstMatch;
}

53
src/lib/locale-utils.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* Shared locale utilities (non-React)
* For the React hook version, see src/hooks/useLocale.ts
*/
/** Get base language code from browser (e.g., "de", "en") */
export function getBrowserLanguage(): string {
const lang = navigator?.language || navigator?.languages?.[0] || "en";
return lang.split("-")[0].toLowerCase();
}
/** Get the full browser locale string (e.g., "en-US", "de-DE") */
export function getBrowserLocale(): string {
return navigator?.language || navigator?.languages?.[0] || "en-US";
}
/**
* Format a language code as a human-friendly name using Intl.DisplayNames.
* Falls back to the raw code if the language is not recognized.
* Example: "de" → "German", "en" → "English"
*/
export function formatLanguageName(languageCode: string): string {
try {
const displayNames = new Intl.DisplayNames([getBrowserLocale()], {
type: "language",
});
return displayNames.of(languageCode) ?? languageCode;
} catch {
return languageCode;
}
}
/**
* Format an ISO date string (e.g., "2024-01-15") in a locale-aware way.
* Returns a human-readable long date like "January 15, 2024".
*/
export function formatISODate(isoDate: string): string {
try {
const parts = isoDate.split("-");
if (parts.length !== 3) return isoDate;
const [year, month, day] = parts.map(Number);
if (isNaN(year) || isNaN(month) || isNaN(day)) return isoDate;
const date = new Date(year, month - 1, day); // local time, no UTC shift
if (isNaN(date.getTime())) return isoDate;
return date.toLocaleDateString(getBrowserLocale(), {
year: "numeric",
month: "long",
day: "numeric",
});
} catch {
return isoDate;
}
}