diff options
-rw-r--r-- | src/components/DateSelector.astro | 172 | ||||
-rw-r--r-- | src/components/templates/SimplePostList.astro | 91 | ||||
-rw-r--r-- | src/lib/collection/helpers.ts | 172 | ||||
-rw-r--r-- | src/lib/collection/schemas.ts | 9 | ||||
-rw-r--r-- | src/pages/blog/[...date].astro | 251 | ||||
-rw-r--r-- | src/pages/index.astro | 4 |
6 files changed, 463 insertions, 236 deletions
diff --git a/src/components/DateSelector.astro b/src/components/DateSelector.astro index d57919e..ab10fd1 100644 --- a/src/components/DateSelector.astro +++ b/src/components/DateSelector.astro @@ -1,6 +1,8 @@ --- +import { defined } from "@utils/anonymous"; + interface Props { - date: Date; + date?: Date; years: number[]; months: number[]; days?: number[]; @@ -8,9 +10,10 @@ interface Props { const { date, years, months, days } = Astro.props; -const y = date.getFullYear(); -const m = date.getMonth() + 1; -const d = date.getDate(); +const dateParts = Astro.params.date?.split("/").map(Number); +const y = dateParts?.[0]; +const m = dateParts?.[1]; +const d = dateParts?.[2]; let yI = 0; let mI = 0; let dI = 0; @@ -19,23 +22,31 @@ const list = new Intl.ListFormat("pt-PT", { type: "unit", style: "narrow" }); const pad = (n: number) => String(n).padStart(2, "0"); --- -<nav> +<nav class="mute small"> <span role="list"> Anos:{" "} { - list.formatToParts(years.map((y) => - new Intl.DateTimeFormat("pt-PT", { year: "2-digit" }).format( - new Date( - Date.UTC( - y, - 0, - 1, - date.getTimezoneOffset() / 60, - date.getTimezoneOffset() % 60, + list.formatToParts( + years.sort().map((y) => + new Intl.DateTimeFormat("pt-PT", { year: "numeric" }).format( + new Date( + Date.UTC( + y, + 0, + 3, + ...[ + date === undefined + ? undefined + : date.getTimezoneOffset() / 60, + date === undefined + ? undefined + : date.getTimezoneOffset() % 60, + ].filter(defined), + ), ), - ), - ) - )).map(({ type, value }: { type: string; value: string }) => { + ) + ), + ).map(({ type, value }: { type: string; value: string }) => { switch (type) { case "element": { const year = years[yI++]; @@ -53,67 +64,39 @@ const pad = (n: number) => String(n).padStart(2, "0"); }) } </span> - <br /> - <span role="list"> - Meses:{" "} - { - list.formatToParts(months.map((m) => - new Intl.DateTimeFormat("pt-PT", { month: "short" }).format( - new Date( - Date.UTC( - y, - m - 1, - 1, - date.getTimezoneOffset() / 60, - date.getTimezoneOffset() % 60, - ), - ), - ) - )).map(({ type, value }: { type: string; value: string }) => { - switch (type) { - case "element": { - const month = months[mI++]; - return ( - <span role="listitem"><a - class:list={[{ active: month === m }]} - href={`/blog/${y}/${pad(month)}`} - >{value}</a></span> - ); - } - case "literal": { - return <span>{value}</span>; - } - } - }) - } - </span> { - days && - ( + y && ( <><br /><span role="list"> - Dias:{" "} + Meses:{" "} { - list.formatToParts(days.map((d) => { - return new Intl.DateTimeFormat("pt-PT", { day: "numeric" }) - .format( + list.formatToParts( + months.sort().map((m) => + new Intl.DateTimeFormat("pt-PT", { month: "short" }).format( new Date( Date.UTC( y, m - 1, - d, - date.getTimezoneOffset() / 60, - date.getTimezoneOffset() % 60, + 3, + ...[ + date === undefined + ? undefined + : date.getTimezoneOffset() / 60, + date === undefined + ? undefined + : date.getTimezoneOffset() % 60, + ].filter(defined), ), ), - ); - })).map(({ type, value }: { type: string; value: string }) => { + ) + ), + ).map(({ type, value }: { type: string; value: string }) => { switch (type) { case "element": { - const day = days[dI++]; + const month = months[mI++]; return ( <span role="listitem"><a - class:list={[{ active: day === d }]} - href={`/blog/${y}/${pad(m)}/${pad(d)}`} + class:list={[{ active: month === m }]} + href={`/blog/${y}/${pad(month)}`} >{value}</a></span> ); } @@ -126,10 +109,69 @@ const pad = (n: number) => String(n).padStart(2, "0"); </span></> ) } + { + days && + ( + <><br /><span role="list"> + Dias:{" "} + { + y && m && + list.formatToParts( + days.sort().map((d) => { + return new Intl.DateTimeFormat("pt-PT", { + day: "numeric", + }) + .format( + new Date( + Date.UTC( + y, + m - 1, + d, + ...[ + date === undefined + ? undefined + : date.getTimezoneOffset() / 60, + date === undefined + ? undefined + : date.getTimezoneOffset() % 60, + ].filter(defined), + ), + ), + ); + }), + ).map( + ({ type, value }: { type: string; value: string }) => { + switch (type) { + case "element": { + const day = days[dI++]; + return ( + <span role="listitem"><a + class:list={[{ active: day === d }]} + href={`/blog/${y}/${pad(m)}/${pad(day)}`} + >{value}</a></span> + ); + } + case "literal": { + return <span>{value}</span>; + } + } + }, + ) + } + </span></> + ) + } </nav> <style> + nav { + padding-block: calc(var(--size-2) * 1em); + } a.active { font-weight: bolder; } + + a:hover { + color: var(--color-active); + } </style> diff --git a/src/components/templates/SimplePostList.astro b/src/components/templates/SimplePostList.astro index 164b32b..6d05a1f 100644 --- a/src/components/templates/SimplePostList.astro +++ b/src/components/templates/SimplePostList.astro @@ -1,25 +1,45 @@ --- import Date from "@components/organisms/Date.astro"; import KeywordsList from "@components/organisms/KeywordsList.astro"; -import { getFirstUserID, getLastUpdate } from "@lib/collection/helpers"; -import type { Original } from "@lib/collection/schemas"; -import type { z } from "astro:content"; +import { + getFirstUserID, + getLastUpdate, + getTranslationOriginal, + isMicro, + isTranslation, +} from "@lib/collection/helpers"; import type { CollectionEntry } from "astro:content"; interface Props { - posts: (CollectionEntry<"blog"> & { data: z.infer<typeof Original> })[]; + posts: CollectionEntry<"blog">[]; + small?: boolean; + dateOptions?: Intl.DateTimeFormatOptions; } -const { posts } = Astro.props; +const { + posts, + small = false, + dateOptions = { year: "numeric", month: "long", day: "numeric" }, +} = Astro.props; --- -<ol> +<ol class:list={{ "remove-nums": !small }}> { - await Promise.all(posts.map(async (post) => { - const { id, data } = post; - const { title, description, lang, keywords } = data; - const { name, email, entity } = await getFirstUserID(post); - const display = name ?? email ?? entity; - return ( + await Promise.all( + posts.map( + async (post) => { + const { id, data } = post; + const { title, lang } = data; + const description = isMicro(post) + ? post.rendered?.html + : ("description" in data ? data.description : undefined); + const keywords = isTranslation(post) + ? await getTranslationOriginal(post).then((x) => + x?.data?.keywords + ) + : ("keywords" in data ? data.keywords : undefined); + const { name, email, entity } = await getFirstUserID(post); + const display = name ?? email ?? entity; + return ( <li> <article itemprop="blogPost" @@ -29,36 +49,39 @@ const { posts } = Astro.props; <h3> <a href={`/blog/read/${id}`} itemprop="headline name">{title}</a> </h3> - <div itemprop="abstract"> - { - description && - description.split("\n\n").map((paragraph) => ( - <p class="small">{paragraph}</p> - )) - } - </div> + { + description && ( + <div itemprop="abstract"> + { + isMicro(post) + ? <Fragment set:html={description} /> + : description.split("\n\n").map(( + paragraph, + ) => <p class:list={{ small }}>{paragraph}</p>) + } + </div> + ) + } <footer class="small"> <Date date={getLastUpdate(post)} locales={lang} - options={{ - year: "numeric", - month: "long", - day: "numeric", - }} + options={dateOptions} itemprop="dateModified" /><span itemprop="author" itemscope itemtype="https://schema.org/Person" ><span itemprop="alternateName">{display}</span></span> - <KeywordsList {keywords} /> + {Array.isArray(keywords) && <KeywordsList {keywords} />} </footer> </article> </li> ); - })) + }, + ), + ) } </ol> @@ -82,6 +105,20 @@ const { posts } = Astro.props; } } } + + & > li:not(:first-of-type) { + border-block-start: 1px solid var(--color-dark); + } + } + + ol.remove-nums { + margin-inline-start: 0; + & > li { + margin-inline-end: calc(var(--size-7) * 1em); + & > article { + padding-inline-end: 0; + } + } } @media (width >= 40rem) { diff --git a/src/lib/collection/helpers.ts b/src/lib/collection/helpers.ts index 587180b..0bfd3c8 100644 --- a/src/lib/collection/helpers.ts +++ b/src/lib/collection/helpers.ts @@ -13,6 +13,12 @@ import { createKeyFromArmor } from "../pgp/create.ts"; import { getUserIDsFromKey } from "../pgp/user.ts"; import type { UserIDPacket } from "openpgp"; import { getCollection } from "astro:content"; +import { + GetStaticPaths, + GetStaticPathsItem, + GetStaticPathsResult, +} from "astro"; +import { getEntry } from "astro:content"; export function getLastUpdate({ data }: CollectionEntry<"blog">): Date { return data.dateUpdated ?? data.dateCreated; @@ -108,3 +114,169 @@ export const isTranslation = ( export const isMicro = ( entry: CollectionEntry<"blog">, ): entry is MicroEntry => entry.data.kind === "micro"; + +export async function getTranslationOriginal( + translation: TranslationEntry, +): Promise<OriginalEntry | undefined> { + if (!isTranslation(translation)) { + throw new Error(); + } + return await getEntry(translation.data.translationOf); +} + +export const datePaths = (async (): Promise< + { + params: { date?: string }; + props: { + posts: OriginalEntry[]; + next?: string; + previous?: string; + years: number[]; + months: number[]; + days?: number[]; + }; + }[] +> => { + const posts = await fromPosts(isEntry, identity); + + const archive = { + years: new Set<number>(), + monthsByYear: new Map<string, Set<number>>(), + daysByMonth: new Map<string, Set<number>>(), + postsByDate: new Map<string, typeof posts>(), + sortedDates: [] as string[], + }; + + const getYMD = (date: Date) => { + const y = date.getFullYear(); + const m = date.getMonth() + 1; + const d = date.getDate(); + return { y, m, d }; + }; + + for (const post of posts) { + const { y, m, d } = getYMD(post.data.dateCreated); + + archive.years.add(y); + + const months = archive.monthsByYear.get(y.toString()); + if (months === undefined) { + archive.monthsByYear.set(y.toString(), new Set([m])); + } else { + months.add(m); + } + + const ym = `${y}/${String(m).padStart(2, "0")}`; + const days = archive.daysByMonth.get(ym); + if (days === undefined) { + archive.daysByMonth.set(ym, new Set([d])); + } else { + days.add(d); + } + + const ymd = `${ym}/${String(d).padStart(2, "0")}`; + const posts = archive.postsByDate.get(ymd); + if (posts === undefined) { + archive.postsByDate.set(ymd, [post]); + } else { + posts.push(post); + } + } + + archive.sortedDates = Array.from(archive.postsByDate.keys()).sort(); + + const paths: { + params: { date?: string }; + props: { + posts: OriginalEntry[]; + next?: string; + previous?: string; + years: number[]; + months: number[]; + days?: number[]; + }; + }[] = [] satisfies GetStaticPathsItem[]; + + const sortedYears = Array.from(archive.years).sort(); + + const lastYear = Math.max(...sortedYears.map(Number)); + paths.push({ + params: { date: undefined }, + props: { + posts: posts.filter((p) => p.data.dateCreated.getFullYear() === lastYear), + next: undefined, + previous: sortedYears?.[sortedYears.length - 2]?.toString(), + years: sortedYears, + months: Array.from(archive.monthsByYear.get(lastYear.toString()) ?? []), + }, + }); + + for (const y of sortedYears) { + const yearPosts = posts.filter((p) => + p.data.dateCreated.getFullYear() === Number(y) + ); + const idx = sortedYears.indexOf(y); + paths.push({ + params: { date: y.toString() }, + props: { + posts: yearPosts, + next: sortedYears?.[idx + 1]?.toString(), + previous: sortedYears?.[idx - 1]?.toString(), + years: sortedYears, + months: Array.from(archive.monthsByYear.get(y.toString()) ?? []), + }, + }); + } + + const allMonths = Array.from(archive.monthsByYear.entries()) + .flatMap(([year, mset]) => + Array.from(mset).map((m) => `${year}/${String(m).padStart(2, "0")}`) + ) + .sort(); + + for (const [y, months] of archive.monthsByYear) { + const sortedMonths = Array.from(months).sort(); + for (const m of sortedMonths) { + const monthPosts = posts.filter((p) => { + const d = p.data.dateCreated; + return ( + d.getFullYear() === Number(y) && + d.getMonth() + 1 === m + ); + }); + + const ym = `${y}/${String(m).padStart(2, "0")}`; + const idx = allMonths.indexOf(ym); + + paths.push({ + params: { date: ym }, + props: { + posts: monthPosts, + next: allMonths?.[idx + 1], + previous: allMonths?.[idx - 1], + years: sortedYears, + months: Array.from(months).sort(), + days: Array.from(archive.daysByMonth.get(ym) ?? []).sort(), + }, + }); + } + } + + for (let i = 0; i < archive.sortedDates.length; i++) { + const ymd = archive.sortedDates[i]; + const [y, m] = ymd.split("/"); + paths.push({ + params: { date: ymd }, + props: { + posts: archive.postsByDate.get(ymd) ?? [], + next: archive.sortedDates?.[i + 1], + previous: archive.sortedDates?.[i - 1], + years: sortedYears, + months: Array.from(archive.monthsByYear.get(y) ?? []).sort(), + days: Array.from(archive.daysByMonth.get(`${y}/${m}`) ?? []).sort(), + }, + }); + } + + return paths; +}) satisfies GetStaticPaths; diff --git a/src/lib/collection/schemas.ts b/src/lib/collection/schemas.ts index eca996f..f8a021d 100644 --- a/src/lib/collection/schemas.ts +++ b/src/lib/collection/schemas.ts @@ -3,7 +3,14 @@ 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 KEYWORDS = [ + "Portugal", + "democracy", + "test", + "health", + "gym", + "diet", +] as const; export const KeywordsEnum = z.enum(KEYWORDS); export const ENTITY_TYPES = ["author", "co-author", "translator"] as const; diff --git a/src/pages/blog/[...date].astro b/src/pages/blog/[...date].astro index 1742baa..d66ac8e 100644 --- a/src/pages/blog/[...date].astro +++ b/src/pages/blog/[...date].astro @@ -1,161 +1,128 @@ --- import type { - GetStaticPaths, InferGetStaticParamsType, InferGetStaticPropsType, } from "astro"; -import { getCollection } from "astro:content"; import Base from "@layouts/Base.astro"; import DateSelector from "@components/DateSelector.astro"; -import BlogCard from "@components/BlogCard.astro"; -import { sortLastCreated } from "@lib/collection/helpers"; +import SimplePostList from "@components/templates/SimplePostList.astro"; +import { datePaths, sortLastCreated } from "@lib/collection/helpers"; -export const getStaticPaths = (async () => { - const posts = await getCollection("blog"); - - const archive = { - years: new Set<number>(), - monthsByYear: new Map<string, Set<number>>(), - daysByMonth: new Map<string, Set<number>>(), - postsByDate: new Map<string, typeof posts>(), - sortedDates: [] as string[], - }; - - const getYMD = (date: Date) => { - const y = date.getFullYear(); - const m = date.getMonth() + 1; - const d = date.getDate(); - return { y, m, d }; - }; - - for (const post of posts) { - const { y, m, d } = getYMD(post.data.dateCreated); - - archive.years.add(y); - - if (!archive.monthsByYear.has(y.toString())) { - archive.monthsByYear.set(y.toString(), new Set()); - } - archive.monthsByYear.get(y.toString())!.add(m); - - const ym = `${y}/${String(m).padStart(2, "0")}`; - if (!archive.daysByMonth.has(ym)) archive.daysByMonth.set(ym, new Set()); - archive.daysByMonth.get(ym)!.add(d); - - const ymd = `${ym}/${String(d).padStart(2, "0")}`; - if (!archive.postsByDate.has(ymd)) archive.postsByDate.set(ymd, []); - archive.postsByDate.get(ymd)!.push(post); - } - - archive.sortedDates = Array.from(archive.postsByDate.keys()).sort(); - - const paths = []; - - const sortedYears = Array.from(archive.years).sort(); - - const lastYear = Math.max(...sortedYears.map(Number)); - paths.push({ - params: { year: undefined }, - props: { - posts: posts.filter((p) => - p.data.dateCreated.getFullYear() === lastYear - ), - next: undefined, - previous: sortedYears?.[sortedYears.length - 2], - years: sortedYears, - months: Array.from(archive.monthsByYear.get(lastYear.toString()) ?? []), - }, - }); - - for (const y of sortedYears) { - const yearPosts = posts.filter((p) => - p.data.dateCreated.getFullYear() === Number(y) - ); - const idx = sortedYears.indexOf(y); - paths.push({ - params: { year: y }, - props: { - posts: yearPosts, - next: sortedYears?.[idx + 1], - previous: sortedYears?.[idx - 1], - years: sortedYears, - months: Array.from(archive.monthsByYear.get(y.toString()) ?? []), - }, - }); - } - - const allMonths = Array.from(archive.monthsByYear.entries()) - .flatMap(([year, mset]) => - Array.from(mset).map((m) => `${year}/${String(m).padStart(2, "0")}`) - ) - .sort(); - - for (const [y, months] of archive.monthsByYear) { - const sortedMonths = Array.from(months).sort(); - for (const m of sortedMonths) { - const monthPosts = posts.filter((p) => { - const d = p.data.dateCreated; - return ( - d.getFullYear() === Number(y) && - d.getMonth() + 1 === m - ); - }); - - const ym = `${y}/${String(m).padStart(2, "0")}`; - const idx = allMonths.indexOf(ym); - - paths.push({ - params: { year: ym }, - props: { - posts: monthPosts, - next: allMonths?.[idx + 1], - previous: allMonths?.[idx - 1], - years: sortedYears, - months: Array.from(months).sort(), - days: Array.from(archive.daysByMonth.get(ym) ?? []).sort(), - }, - }); - } - } - - for (let i = 0; i < archive.sortedDates.length; i++) { - const ymd = archive.sortedDates[i]; - const [y, m] = ymd.split("/"); - paths.push({ - params: { year: ymd }, - props: { - posts: archive.postsByDate.get(ymd) ?? [], - next: archive.sortedDates?.[i + 1], - previous: archive.sortedDates?.[i - 1], - years: sortedYears, - months: Array.from(archive.monthsByYear.get(y) ?? []).sort(), - days: Array.from(archive.daysByMonth.get(`${y}/${m}`) ?? []).sort(), - }, - }); - } - - return paths; -}) satisfies GetStaticPaths; +export const getStaticPaths = datePaths; export type Params = InferGetStaticParamsType<typeof getStaticPaths>; export type Props = InferGetStaticPropsType<typeof getStaticPaths>; -const title = "Blog"; -const description = "Latest articles."; - let { posts, previous, next, years, months, days } = Astro.props; posts = posts.sort(sortLastCreated); -const date = posts[0].data.dateCreated as Date; + +const dateParts = Astro.params.date?.split("/").map(Number); +const y = dateParts?.[0]; +const m = dateParts?.[1] ?? 1; +const d = dateParts?.[2] ?? 3; +const date = (y !== undefined) ? new Date(Date.UTC(y, m - 1, d)) : undefined; + +const format = date === undefined + ? undefined + : new Intl.DateTimeFormat("pt-PT", { + year: y === undefined ? undefined : "numeric", + month: dateParts?.[1] === undefined ? undefined : "long", + day: dateParts?.[2] === undefined ? undefined : "numeric", + }).format(date); +const title = "Publicações" + (format !== undefined ? ` -- ${format}` : ""); +const description = "Ultímas publicações" + + (format !== undefined ? ` do dia ${format}` : "") + "."; --- <Base {title} {description}> - <main> - <h2>Blogue</h2> - {date && <DateSelector {date} {years} {months} {days} />} - {posts.map((post) => <BlogCard {...post} />)} - <div> - {previous && <a href={`/blog/${Astro.props.previous}`}>Previous</a>} - {next && <a href={`/blog/${Astro.props.next}`}>Next</a>} - </div> + <main + itemprop="mainContentOfPage" + itemscope + itemtype="https://schema.org/WebPageElement" + > + <section + id="posts" + itemprop="citation" + itemscope + itemtype="http://schema.org/Blog" + > + <h2 itemprop="name description">{title}</h2> + <DateSelector {date} {years} {months} {days} /> + { + (next || previous) && ( + <nav> + { + previous && ( + <p> + < <a href={`/blog/${Astro.props.previous}`}>Anterior</a> + </p> + ) + } + <span class="small">{format}</span> + { + next && ( + <p><a href={`/blog/${Astro.props.next}`}>Próximo</a> ></p> + ) + } + </nav> + ) + } + <SimplePostList + {posts} + dateOptions={{ + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZoneName: "long", + }} + /> + { + (next || previous) && ( + <nav> + { + previous && ( + <p> + < <a href={`/blog/${Astro.props.previous}`}>Anterior</a> + </p> + ) + } + <span class="small">{format}</span> + { + next && ( + <p><a href={`/blog/${Astro.props.next}`}>Próximo</a> ></p> + ) + } + </nav> + ) + } + <DateSelector {date} {years} {months} {days} /> + </section> </main> </Base> + +<style> + nav { + display: flex; + align-items: center; + padding-block: calc(var(--size-2) * 1em); + gap: calc(var(--size-2) * 1em); + justify-content: center; + & > p { + border-radius: calc(var(--size-0) * 1em); + border-width: 1px; + box-shadow: 0 1px 2px var(--color-light); + font-size: calc(var(--size-3) * 1rem); + font-weight: 500; + padding-inline: calc(var(--size-3) * 1em); + padding-block: calc(var(--size-2) * 1em); + display: inline-flex; + gap: calc(var(--size-2) * 1em); + align-items: center; + justify-content: center; + height: 2ch; + } + } +</style> diff --git a/src/pages/index.astro b/src/pages/index.astro index 5a52100..bb4108c 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -56,7 +56,9 @@ const micro = await fromPosts( > <h2 itemprop="name description">Últimas publicações atualizadas</h2> {micro && <div id="last-micro"><MicroBlog {...micro} /></div>} - <div id="last-originals"><SimplePostList posts={originals} /></div> + <div id="last-originals"> + <SimplePostList posts={originals} small /> + </div> </section> ) } |