summaryrefslogtreecommitdiff
path: root/src/lib/collection
diff options
context:
space:
mode:
authorJoão Augusto Costa Branco Marado Torres <torres.dev@disroot.org>2025-06-28 18:14:22 -0300
committerJoão Augusto Costa Branco Marado Torres <torres.dev@disroot.org>2025-06-28 18:14:22 -0300
commit79fd506d30eef3d113f4a8e3ab9ebd9004f1e8cc (patch)
tree96ff57c92e897c3cc3331e23043d20f1665c7d0a /src/lib/collection
parenta1eac976b20e39f86d5944fbec68e2a0f8ffb746 (diff)
feat: index page
Signed-off-by: João Augusto Costa Branco Marado Torres <torres.dev@disroot.org>
Diffstat (limited to 'src/lib/collection')
-rw-r--r--src/lib/collection/helpers.ts107
-rw-r--r--src/lib/collection/schemas.ts133
2 files changed, 240 insertions, 0 deletions
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<typeof Blog>["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<string | undefined> {
+ 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<UserIDPacket> & { 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<T extends Entry, U>(
+ filter: (entry: CollectionEntry<"blog">) => entry is T,
+ predicate: (entries: T[]) => U = identity as (entries: T[]) => U,
+): Promise<U> {
+ 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<typeof Original>;
+};
+export type TranslationEntry = CollectionEntry<"blog"> & {
+ data: z.infer<typeof Translation>;
+};
+export type MicroEntry = CollectionEntry<"blog"> & {
+ data: z.infer<typeof Micro>;
+};
+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() }),
+});