mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-28 15:58:06 +02:00
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:
363
src/lib/amb-helpers.test.ts
Normal file
363
src/lib/amb-helpers.test.ts
Normal 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
280
src/lib/amb-helpers.ts
Normal 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
53
src/lib/locale-utils.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user