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

View File

@@ -1,6 +1,7 @@
import { getNIPInfo } from "../lib/nip-icons";
import { useAddWindow } from "@/core/state";
import { isNipDeprecated } from "@/constants/nips";
import { getCommunityNipForNipId } from "@/constants/kinds";
export interface NIPBadgeProps {
nipNumber: string;
@@ -26,7 +27,19 @@ export function NIPBadge({
nipInfo?.description || `Nostr Implementation Possibility ${nipNumber}`;
const isDeprecated = isNipDeprecated(nipNumber);
const communityNip = getCommunityNipForNipId(nipNumber);
const openNIP = () => {
if (communityNip) {
const pointer = {
kind: 30817,
pubkey: communityNip.pubkey,
identifier: communityNip.identifier,
relays: communityNip.relayHints,
};
addWindow("open", { pointer }, undefined, communityNip.title);
return;
}
const paddedNum = nipNumber.toString().padStart(2, "0");
addWindow(
"nip",
@@ -41,7 +54,13 @@ export function NIPBadge({
className={`flex items-center gap-2 border bg-card px-2.5 py-1.5 text-sm hover:underline hover:decoration-dotted cursor-crosshair ${
isDeprecated ? "opacity-50" : ""
} ${className}`}
title={isDeprecated ? `${description} (DEPRECATED)` : description}
title={
isDeprecated
? `${description} (DEPRECATED)`
: communityNip
? `${description} (Community NIP)`
: description
}
>
<span className="text-muted-foreground">
{`${showNIPPrefix ? "NIP-" : ""}${nipNumber}`}

View File

@@ -3,9 +3,11 @@ import { Copy, CopyCheck } from "lucide-react";
import { getTagValue } from "applesauce-core/helpers";
import { UserName } from "../UserName";
import { MarkdownContent } from "../MarkdownContent";
import { KindBadge } from "@/components/KindBadge";
import { Button } from "@/components/ui/button";
import { useCopy } from "@/hooks/useCopy";
import { formatTimestamp } from "@/hooks/useLocale";
import { getTagValues } from "@/lib/nostr-utils";
import { toast } from "sonner";
import type { NostrEvent } from "@/types/nostr";
@@ -24,6 +26,12 @@ export function CommunityNIPDetailRenderer({ event }: { event: NostrEvent }) {
return getTagValue(event, "r");
}, [event]);
const kinds = useMemo(() => {
return getTagValues(event, "k")
.map(Number)
.filter((n) => !isNaN(n));
}, [event]);
// Format created date using locale utility
const createdDate = formatTimestamp(event.created_at, "long");
@@ -65,6 +73,19 @@ export function CommunityNIPDetailRenderer({ event }: { event: NostrEvent }) {
{/* NIP Content - Markdown */}
<MarkdownContent content={event.content} canonicalUrl={canonicalUrl} />
{kinds.length > 0 && (
<div className="mt-6 pt-4 border-t border-border">
<h3 className="text-sm font-bold mb-3">
Event Kinds Defined in {title}
</h3>
<div className="flex flex-wrap gap-2">
{kinds.map((kind) => (
<KindBadge key={kind} kind={kind} variant="full" clickable />
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,306 @@
import { useMemo } from "react";
import { NostrEvent } from "@/types/nostr";
import { useAddWindow } from "@/core/state";
import {
getAmbName,
getAmbDescription,
getAmbImage,
getAmbLanguage,
getAmbTypes,
getAmbKeywords,
getAmbCreators,
getAmbLearningResourceType,
getAmbEducationalLevel,
getAmbAudience,
getAmbSubjects,
getAmbLicenseId,
getAmbIsAccessibleForFree,
getAmbExternalUrls,
getAmbRelatedResources,
getAmbDateCreated,
getAmbDatePublished,
} from "@/lib/amb-helpers";
import { Label } from "@/components/ui/label";
import { UserName } from "@/components/nostr/UserName";
import { MediaEmbed } from "../MediaEmbed";
import { ExternalLink } from "@/components/ExternalLink";
import { formatLanguageName, formatISODate } from "@/lib/locale-utils";
interface EducationalResourceDetailRendererProps {
event: NostrEvent;
}
/**
* Detail renderer for Kind 30142 - Educational Resource (AMB)
* Full metadata view with all AMB properties
*/
export function EducationalResourceDetailRenderer({
event,
}: EducationalResourceDetailRendererProps) {
const addWindow = useAddWindow();
const name = getAmbName(event);
const description = getAmbDescription(event);
const image = getAmbImage(event);
const language = getAmbLanguage(event);
const types = getAmbTypes(event);
const keywords = getAmbKeywords(event);
const creators = getAmbCreators(event);
const learningResourceType = getAmbLearningResourceType(event);
const educationalLevel = getAmbEducationalLevel(event);
const audience = getAmbAudience(event);
const subjects = getAmbSubjects(event);
const licenseId = getAmbLicenseId(event);
const isAccessibleForFree = getAmbIsAccessibleForFree(event);
const externalUrls = getAmbExternalUrls(event);
const relatedResources = getAmbRelatedResources(event);
const dateCreated = getAmbDateCreated(event);
const datePublished = getAmbDatePublished(event);
const licenseLabel = useMemo(() => {
if (!licenseId) return undefined;
// Extract short CC label from URI
const ccMatch = licenseId.match(
/creativecommons\.org\/licenses?\/([\w-]+)/,
);
if (ccMatch) return `CC ${ccMatch[1].toUpperCase()}`;
return licenseId;
}, [licenseId]);
const handleRelatedClick = (address: string) => {
try {
const [kindStr, pubkey, ...identifierParts] = address.split(":");
const pointer = {
kind: parseInt(kindStr),
pubkey,
identifier: identifierParts.join(":"),
};
addWindow("open", { pointer });
} catch {
// ignore malformed address
}
};
return (
<div className="flex flex-col gap-4 p-6">
{/* Header */}
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-bold">{name || "Untitled Resource"}</h1>
{types.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{types.map((type) => (
<Label key={type} size="md">
{type}
</Label>
))}
</div>
)}
{externalUrls[0] && (
<ExternalLink href={externalUrls[0]} size="sm">
{formatReferenceLabel(externalUrls[0])}
</ExternalLink>
)}
</div>
{/* Image */}
{image && <MediaEmbed url={image} preset="preview" enableZoom />}
{/* Description */}
{description && <p className="text-sm">{description}</p>}
{/* Metadata Grid */}
<div className="grid grid-cols-2 gap-3 py-2 text-sm">
{language && (
<MetadataField label="Language">
{formatLanguageName(language)}
</MetadataField>
)}
{educationalLevel && (
<MetadataField label="Educational Level">
{educationalLevel.label || educationalLevel.id}
</MetadataField>
)}
{learningResourceType && (
<MetadataField label="Resource Type">
{learningResourceType.label || learningResourceType.id}
</MetadataField>
)}
{audience && (
<MetadataField label="Audience">
{audience.label || audience.id}
</MetadataField>
)}
{licenseId && (
<MetadataField label="License">
{licenseId.startsWith("http") ? (
<ExternalLink href={licenseId} variant="default" size="sm">
{licenseLabel}
</ExternalLink>
) : (
<span>{licenseLabel}</span>
)}
</MetadataField>
)}
{isAccessibleForFree !== undefined && (
<MetadataField label="Free Access">
{isAccessibleForFree ? "Yes" : "No"}
</MetadataField>
)}
{dateCreated && (
<MetadataField label="Created">
{formatISODate(dateCreated)}
</MetadataField>
)}
{datePublished && (
<MetadataField label="Published">
{formatISODate(datePublished)}
</MetadataField>
)}
</div>
{/* Creators */}
{creators.length > 0 && (
<Section title="Creators">
<div className="flex flex-col gap-2">
{creators.map((creator, i) => (
<div
key={creator.pubkey || creator.name || i}
className="text-sm"
>
<div className="flex items-center gap-2">
{creator.pubkey ? (
<UserName
pubkey={creator.pubkey}
relayHints={
creator.relayHint ? [creator.relayHint] : undefined
}
/>
) : (
<span className="font-medium">
{creator.name || "Unknown"}
</span>
)}
{creator.type && (
<span className="text-muted-foreground">
({creator.type})
</span>
)}
</div>
{creator.affiliationName && (
<span className="text-xs text-muted-foreground ml-1">
{creator.affiliationName}
</span>
)}
</div>
))}
</div>
</Section>
)}
{/* Subjects */}
{subjects.length > 0 && (
<Section title="Subjects">
<div className="flex flex-wrap gap-1.5">
{subjects.map((subject, i) => (
<Label key={subject.id || i} size="md">
{subject.label || subject.id}
</Label>
))}
</div>
</Section>
)}
{/* Keywords */}
{keywords.length > 0 && (
<Section title="Keywords">
<div className="flex flex-wrap gap-1.5">
{keywords.map((kw) => (
<Label key={kw}>{kw}</Label>
))}
</div>
</Section>
)}
{/* External References */}
{externalUrls.length > 0 && (
<Section title="References">
<div className="flex flex-col gap-1">
{externalUrls.map((url) => (
<ExternalLink key={url} href={url} variant="default" size="sm">
{formatReferenceLabel(url)}
</ExternalLink>
))}
</div>
</Section>
)}
{/* Related Resources */}
{relatedResources.length > 0 && (
<Section title="Related Resources">
<div className="flex flex-col gap-1">
{relatedResources.map((res, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<button
onClick={() => handleRelatedClick(res.address)}
className="text-primary hover:underline cursor-crosshair truncate"
>
{res.address.split(":").slice(2).join(":") || res.address}
</button>
{res.relationship && <Label>{res.relationship}</Label>}
</div>
))}
</div>
</Section>
)}
</div>
);
}
function MetadataField({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-1">
<h3 className="text-muted-foreground">{label}</h3>
<span>{children}</span>
</div>
);
}
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-muted-foreground">{title}</h2>
{children}
</div>
);
}
/** Format a reference URL for display (detect DOI, ISBN) */
function formatReferenceLabel(url: string): string {
if (url.includes("doi.org/")) {
const doi = url.split("doi.org/")[1];
return `DOI: ${doi}`;
}
if (url.startsWith("urn:isbn:")) {
return `ISBN: ${url.replace("urn:isbn:", "")}`;
}
return url;
}

View File

@@ -0,0 +1,117 @@
import {
BaseEventContainer,
BaseEventProps,
ClickableEventTitle,
} from "./BaseEventRenderer";
import {
getAmbName,
getAmbDescription,
getAmbLanguage,
getAmbTypes,
getAmbKeywords,
getAmbEducationalLevel,
getAmbLearningResourceType,
getAmbCreators,
getAmbExternalUrls,
} from "@/lib/amb-helpers";
import { Label } from "@/components/ui/label";
import { UserName } from "@/components/nostr/UserName";
import { ExternalLink } from "lucide-react";
import { formatLanguageName } from "@/lib/locale-utils";
/**
* Feed renderer for Kind 30142 - Educational Resource (AMB)
* Compact card showing title, types, language, description, keywords, and creators
*/
export function EducationalResourceRenderer({ event }: BaseEventProps) {
const name = getAmbName(event);
const description = getAmbDescription(event);
const language = getAmbLanguage(event);
const types = getAmbTypes(event);
const keywords = getAmbKeywords(event);
const educationalLevel = getAmbEducationalLevel(event);
const learningResourceType = getAmbLearningResourceType(event);
const creators = getAmbCreators(event);
const externalUrls = getAmbExternalUrls(event);
const primaryUrl = externalUrls[0];
const displayUrl = primaryUrl?.replace(/^https?:\/\//, "").replace(/\/$/, "");
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2 min-w-0">
{/* Title */}
<ClickableEventTitle
event={event}
className="text-lg font-semibold text-foreground"
>
{name || "Untitled Resource"}
</ClickableEventTitle>
{/* Primary URL */}
{primaryUrl && (
<a
href={primaryUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-muted-foreground hover:underline hover:decoration-dotted"
>
<ExternalLink className="size-4 flex-shrink-0" />
<span className="text-sm truncate">{displayUrl}</span>
</a>
)}
{/* Badges row: types, language, educational level, resource type */}
<div className="flex flex-wrap items-center gap-1.5">
{types.map((type) => (
<Label key={type}>{type}</Label>
))}
{language && <Label>{formatLanguageName(language)}</Label>}
{educationalLevel?.label && <Label>{educationalLevel.label}</Label>}
{learningResourceType?.label && (
<Label>{learningResourceType.label}</Label>
)}
</div>
{/* Description */}
{description && (
<p className="text-xs text-muted-foreground line-clamp-2">
{description}
</p>
)}
{/* Keywords */}
{keywords.length > 0 && (
<div className="flex flex-wrap gap-1">
{keywords.map((kw) => (
<Label key={kw} className="text-primary/80">
{kw}
</Label>
))}
</div>
)}
{/* Creators */}
{creators.length > 0 && (
<div className="flex flex-wrap items-center gap-1 text-xs text-muted-foreground">
<span>by</span>
{creators.map((creator, i) => (
<span key={creator.pubkey || creator.name || i}>
{i > 0 && ", "}
{creator.pubkey ? (
<UserName
pubkey={creator.pubkey}
relayHints={
creator.relayHint ? [creator.relayHint] : undefined
}
/>
) : (
<span>{creator.name || "Unknown"}</span>
)}
</span>
))}
</div>
)}
</div>
</BaseEventContainer>
);
}

View File

@@ -170,6 +170,8 @@ import {
MusicTrackDetailRenderer,
} from "./MusicTrackRenderer";
import { PlaylistRenderer, PlaylistDetailRenderer } from "./PlaylistRenderer";
import { EducationalResourceRenderer } from "./EducationalResourceRenderer";
import { EducationalResourceDetailRenderer } from "./EducationalResourceDetailRenderer";
/**
* Registry of kind-specific renderers
@@ -245,6 +247,7 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
30023: Kind30023Renderer, // Long-form Article
30030: EmojiSetRenderer, // Emoji Sets (NIP-30)
30063: ZapstoreReleaseRenderer, // Zapstore App Release
30142: EducationalResourceRenderer, // Educational Resource (AMB)
30166: RelayDiscoveryRenderer, // Relay Discovery (NIP-66)
30267: ZapstoreAppSetRenderer, // Zapstore App Collection
30311: LiveActivityRenderer, // Live Streaming Event (NIP-53)
@@ -358,6 +361,7 @@ const detailRenderers: Record<
30023: Kind30023DetailRenderer, // Long-form Article Detail
30030: EmojiSetDetailRenderer, // Emoji Sets Detail (NIP-30)
30063: ZapstoreReleaseDetailRenderer, // Zapstore App Release Detail
30142: EducationalResourceDetailRenderer, // Educational Resource Detail (AMB)
30166: RelayDiscoveryDetailRenderer, // Relay Discovery Detail (NIP-66)
30267: ZapstoreAppSetDetailRenderer, // Zapstore App Collection Detail
30311: LiveActivityDetailRenderer, // Live Streaming Event Detail (NIP-53)

View File

@@ -27,6 +27,7 @@ import {
GitBranch,
GitMerge,
GitPullRequest,
GraduationCap,
BookHeart,
HardDrive,
Hash,
@@ -74,12 +75,20 @@ import {
type LucideIcon,
} from "lucide-react";
export interface CommunityNip {
title: string;
identifier: string;
pubkey: string;
relayHints?: string[];
}
export interface EventKind {
kind: number | string;
name: string;
description: string;
nip: string;
icon: LucideIcon;
communityNip?: CommunityNip;
}
export const SPELL_KIND = 777;
@@ -1211,6 +1220,24 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
nip: "78",
icon: Settings,
},
30142: {
kind: 30142,
name: "Educational Resource",
description: "AMB Educational Resource Metadata",
nip: "AMB",
icon: GraduationCap,
communityNip: {
title: "NIP-AMB",
identifier: "edufeed-amb",
pubkey:
"bdc21f93b1e2cb75608cecd7a0a00a779779d9367dc9798bd9f213f06c95bc48",
relayHints: [
"wss://relay.nostr.band",
"wss://nos.lol",
"wss://relay.damus.io",
],
},
},
30166: {
kind: 30166,
name: "Relay Discovery",
@@ -1525,3 +1552,14 @@ export function getKindName(kind: number): string {
export function getKindIcon(kind: number): LucideIcon {
return EVENT_KINDS[kind]?.icon || MessageSquare;
}
export function getCommunityNipForNipId(
nipId: string,
): CommunityNip | undefined {
for (const entry of Object.values(EVENT_KINDS)) {
if (entry.nip === nipId && entry.communityNip) {
return entry.communityNip;
}
}
return undefined;
}

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;
}
}