From 79fd506d30eef3d113f4a8e3ab9ebd9004f1e8cc Mon Sep 17 00:00:00 2001 From: João Augusto Costa Branco Marado Torres Date: Sat, 28 Jun 2025 18:14:22 -0300 Subject: feat: index page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: João Augusto Costa Branco Marado Torres --- src/lib/collection/helpers.ts | 107 +++++++++++++++++++++++++++++++++ src/lib/collection/schemas.ts | 133 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 240 insertions(+) create mode 100644 src/lib/collection/helpers.ts create mode 100644 src/lib/collection/schemas.ts (limited to 'src/lib/collection') diff --git a/src/lib/collection/helpers.ts b/src/lib/collection/helpers.ts new file mode 100644 index 0000000..83eb21d --- /dev/null +++ b/src/lib/collection/helpers.ts @@ -0,0 +1,107 @@ +import type { CollectionEntry } from "astro:content"; +import { + Blog, + Entity, + type Entry, + type MicroEntry, + type OriginalEntry, + type TranslationEntry, +} from "./schemas.ts"; +import { getEntries, type z } from "astro:content"; +import { defined, get, identity } from "../../utils/anonymous.ts"; +import { createKeyFromArmor } from "../pgp/create.ts"; +import { getUserIDsFromKey } from "../pgp/user.ts"; +import type { UserIDPacket } from "openpgp"; +import { getCollection } from "astro:content"; + +export function getLastUpdate({ data }: CollectionEntry<"blog">): Date { + return data.dateUpdated ?? data.dateCreated; +} +export const sortLastCreated = ( + { data: a }: CollectionEntry<"blog">, + { data: b }: CollectionEntry<"blog">, +): number => b.dateCreated - a.dateCreated; +export const sortFirstCreated = ( + a: CollectionEntry<"blog">, + b: CollectionEntry<"blog">, +): number => sortLastCreated(b, a); +export const sortLastUpdated = ( + { data: a }: CollectionEntry<"blog">, + { data: b }: CollectionEntry<"blog">, +): number => + (b.dateUpdated ?? b.dateCreated) - (a.dateUpdated ?? a.dateCreated); +export const sortFirstUpdated = ( + a: CollectionEntry<"blog">, + b: CollectionEntry<"blog">, +): number => sortLastUpdated(b, a); + +export async function getSigners( + { data }: CollectionEntry<"blog">, +): Promise<{ + id: string; + entity: CollectionEntry<"entity">; + role: z.infer["signers"][number]["role"] | undefined; +}[]> { + const post = Blog.parse(data); + return await getEntries(post.signers.map(get("entity"))).then((x) => + x.map((x) => ({ + id: x.id, + entity: x, + role: post.signers?.find((y) => y.entity.id === x.id)?.role, + })).filter(({ role }) => defined(role)) + ); +} + +export async function getFirstAuthorEmail( + blog: CollectionEntry<"blog">, +): Promise { + const signers = await getSigners(blog); + const emails = await Promise.all( + signers.filter(({ role }) => role === "author").map(async ({ entity }) => { + const { publickey } = Entity.parse(entity.data); + const key = await createKeyFromArmor(publickey.armor); + const users = getUserIDsFromKey(undefined, key); + return users.map(get("email")).filter(Boolean)?.[0]; + }), + ); + return emails.filter(defined)?.[0]; +} + +export async function getFirstUserID( + blog: CollectionEntry<"blog">, +): Promise< + (Partial & { entity: string; website: string | undefined }) +> { + const signers = await getSigners(blog); + const userIDs = await Promise.all( + signers.filter(({ role }) => role === "author").map( + async ({ id, entity }) => { + const { publickey, websites } = Entity.parse(entity.data); + const website = websites?.[0]; + const key = await createKeyFromArmor(publickey.armor); + const users = getUserIDsFromKey(undefined, key); + return users.map((user) => { + return { ...user, entity: id, website }; + })?.[0]; + }, + ), + ); + return userIDs.filter(defined)?.[0]; +} + +export async function fromPosts( + filter: (entry: CollectionEntry<"blog">) => entry is T, + predicate: (entries: T[]) => U = identity as (entries: T[]) => U, +): Promise { + const entries = await getCollection<"blog", T>("blog", filter); + return predicate(entries); +} +export const isOriginal = ( + entry: CollectionEntry<"blog">, +): entry is OriginalEntry => entry.data.kind === "original"; +export const isTranslation = ( + entry: CollectionEntry<"blog">, +): entry is TranslationEntry => entry.data.kind === "translation"; +export const isMicro = ( + entry: CollectionEntry<"blog">, +): entry is MicroEntry => entry.data.kind === "micro"; diff --git a/src/lib/collection/schemas.ts b/src/lib/collection/schemas.ts new file mode 100644 index 0000000..eca996f --- /dev/null +++ b/src/lib/collection/schemas.ts @@ -0,0 +1,133 @@ +import { reference, z } from "astro:content"; +import { isValidLocale } from "../../utils/lang.ts"; +import { get } from "../../utils/anonymous.ts"; +import type { CollectionEntry } from "astro:content"; + +export const KEYWORDS = ["Portugal", "democracy", "test"] as const; +export const KeywordsEnum = z.enum(KEYWORDS); + +export const ENTITY_TYPES = ["author", "co-author", "translator"] as const; +export const EntityTypesEnum = z.enum(ENTITY_TYPES); + +export const CREATIVE_COMMONS_LICENSES = [ + "CC0", + "CC-BY", + "CC-BY-SA", + "CC-BY-ND", + "CC-BY-NC", + "CC-BY-NC-SA", + "CC-BY-NC-ND", +] as const; +export const LICENSES = [ + ...CREATIVE_COMMONS_LICENSES, + "WTFPL", + "public domain", +] as const; +export const LicensesEnum = z.enum(LICENSES); + +export const Original = z.object({ + kind: z.literal("original"), + title: z.string().trim(), + subtitle: z.string().trim().optional(), + description: z.string().trim().optional(), + keywords: z.array(KeywordsEnum).refine( + (keywords) => new Set(keywords).size === keywords.length, + { message: "Keywords must be unique" }, + ), + dateCreated: z.coerce.date(), + dateUpdated: z.coerce.date().optional(), + locationCreated: z.string().trim().optional(), + relatedPosts: z.array(reference("blog")).default([]).refine( + (posts) => new Set(posts).size === posts.length, + { message: "Related posts referenced multiple times" }, + ), + lang: z.string().trim().refine(isValidLocale), + signers: z.array( + z.object({ entity: reference("entity"), role: EntityTypesEnum }), + ).default([]).refine( + (signers) => signers.filter((s) => s.role === "author").length <= 1, + { message: "There can only be one author" }, + ).refine((signers) => { + const ids = signers.map(get("entity")); + return new Set(ids).size === ids.length; + }, { message: "Reusing signers" }).refine( + (signers) => signers.every(({ role }) => role !== "translator"), + { message: "There can't be translator signers on non translated work" }, + ), + license: LicensesEnum.default("public domain"), +}); + +export const Translation = z.object({ + kind: z.literal("translation"), + title: z.string().trim(), + subtitle: z.string().trim().optional(), + description: z.string().trim().optional(), + dateCreated: z.coerce.date(), + dateUpdated: z.coerce.date().optional(), + lang: z.string().trim().refine(isValidLocale), + translationOf: reference("blog"), + signers: z.array( + z.object({ entity: reference("entity"), role: EntityTypesEnum }), + ).default([]).refine( + (signers) => signers.filter((s) => s.role === "author").length <= 1, + { message: "There can only be one author" }, + ).refine((signers) => { + const ids = signers.map(get("entity")); + return new Set(ids).size === ids.length; + }, { message: "Reusing signers" }), + license: LicensesEnum.default("public domain"), +}); + +export const Micro = z.object({ + kind: z.literal("micro"), + title: z.string().trim(), + keywords: z.array(KeywordsEnum).refine( + (keywords) => new Set(keywords).size === keywords.length, + { message: "Keywords must be unique" }, + ), + dateCreated: z.coerce.date(), + dateUpdated: z.coerce.date().optional(), + locationCreated: z.string().trim().optional(), + lang: z.string().trim().refine(isValidLocale), + signers: z.array( + z.object({ entity: reference("entity"), role: EntityTypesEnum }), + ).default([]).refine( + (signers) => signers.filter((s) => s.role === "author").length <= 1, + { message: "There can only be one author" }, + ).refine((signers) => { + const ids = signers.map(get("entity")); + return new Set(ids).size === ids.length; + }, { message: "Reusing signers" }).refine( + (signers) => signers.every(({ role }) => role !== "translator"), + { message: "There can't be translator signers on non translated work" }, + ), + license: LicensesEnum.default("public domain"), +}); + +export const Blog = z.discriminatedUnion("kind", [ + Original, + Translation, + Micro, +]).refine( + ({ dateCreated, dateUpdated }) => + dateUpdated === undefined || dateCreated.getTime() <= dateUpdated.getTime(), + { message: "Update before creation" }, +); + +export type OriginalEntry = CollectionEntry<"blog"> & { + data: z.infer; +}; +export type TranslationEntry = CollectionEntry<"blog"> & { + data: z.infer; +}; +export type MicroEntry = CollectionEntry<"blog"> & { + data: z.infer; +}; +export type Entry = OriginalEntry | TranslationEntry | MicroEntry; + +export const Entity = z.object({ + websites: z.array(z.string().url().trim()).default([]).transform((websites) => + websites.map((x) => new URL(x).href) + ), + publickey: z.object({ armor: z.string().trim() }), +}); -- cgit v1.2.3