diff options
author | João Augusto Costa Branco Marado Torres <torres.dev@disroot.org> | 2025-07-06 22:27:04 -0300 |
---|---|---|
committer | João Augusto Costa Branco Marado Torres <torres.dev@disroot.org> | 2025-07-06 22:27:04 -0300 |
commit | 449f71511e539878af4c65f073cdc373b469c45e (patch) | |
tree | 6c264a77426284d97d1c9b9d7a27a6015c3077a8 | |
parent | 4ae2b810b68538ba4c287b0c80d6c2e002fe9ddd (diff) |
feat: more structured data
Signed-off-by: João Augusto Costa Branco Marado Torres <torres.dev@disroot.org>
-rw-r--r-- | src/components/BaseHead.astro | 15 | ||||
-rw-r--r-- | src/components/Footer.astro | 25 | ||||
-rw-r--r-- | src/components/Header.astro | 3 | ||||
-rw-r--r-- | src/components/organisms/Date.astro | 3 | ||||
-rw-r--r-- | src/components/organisms/KeywordsList.astro | 2 | ||||
-rw-r--r-- | src/components/templates/MicroBlog.astro | 27 | ||||
-rw-r--r-- | src/components/templates/Search.astro | 15 | ||||
-rw-r--r-- | src/components/templates/SimplePostList.astro | 33 | ||||
-rw-r--r-- | src/layouts/Base.astro | 6 | ||||
-rw-r--r-- | src/pages/blog/read/[...slug].astro | 420 | ||||
-rw-r--r-- | src/pages/index.astro | 15 |
11 files changed, 371 insertions, 193 deletions
diff --git a/src/components/BaseHead.astro b/src/components/BaseHead.astro index 885447c..b57127a 100644 --- a/src/components/BaseHead.astro +++ b/src/components/BaseHead.astro @@ -58,13 +58,22 @@ const { type="text/html" title={`${isOnion ? "Clearnet" : "Tor"} version`} > +<link + rel="license" + href="https://www.gnu.org/licenses/agpl-3.0.html" + itemprop="license" +/> <!-- Primary Meta Tags --> <title>{title}</title> -<meta name="title" content={title} /> -<meta name="description" content={description} /> +<meta name="title" content={title} itemprop="name" /> +<meta name="description" content={description} itemprop="description" /> <meta name="author" content={PUBLIC_SITE_AUTHOR} /> -{keywords.length > 0 && <meta name="keywords" content={keywords.join(",")} />} +{ + keywords.length > 0 && ( + <meta name="keywords" content={keywords.join(",")} itemprop="keywords" /> + ) +} <meta name="theme-color" content="oklch(0.4564 0.1835 20.81)" /> <meta name="theme-color" diff --git a/src/components/Footer.astro b/src/components/Footer.astro index 3238c50..f69ba8a 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -22,10 +22,20 @@ const git = isOnion ? PUBLIC_GIT_TOR_URL ?? PUBLIC_GIT_URL : PUBLIC_GIT_URL; </p> ) } - <address> + <address + itemprop="maintainer author" + itemscope + itemtype="https://schema.org/Person" + > + <link href={site} rel="author" itemprop="url" /> <p> - Sítio web de <a href={site} target="_blank" rel="author" - >João Augusto Costa Branco Marado Torres</a> + Sítio web de <a href={site} target="_blank" rel="author" itemprop="name" + >João Augusto Costa Branco Marado Torres</a> <<a + href="mailto:torres.dev@disroot.pt" + target="_blank" + rel="author" + itemprop="email" + >torres.dev@disroot.pt</a>> </p> { PUBLIC_SIMPLE_X_ADDRESS && ( @@ -42,10 +52,15 @@ const git = isOnion ? PUBLIC_GIT_TOR_URL ?? PUBLIC_GIT_URL : PUBLIC_GIT_URL; </p> <section id="copying" class="mute"> <h2 class="sr-only">Licença de <span lang="en">Software</span></h2> - <div lang="en"> + <div lang="en" itemprop="copyrightNotice"> <p> <<a href="/" hreflang="pt-PT">cravodeabril.pt</a>> Copyright - © 2025 João Augusto Costa Branco Marado Torres + © <time datetime="2025" itemprop="copyrightYear">2025</time> <span + itemprop="copyrightHolder" + itemscope + itemtype="Person" + ><span itemprop="name" + >João Augusto Costa Branco Marado Torres</span></span> </p> <p> This program is free software: you can redistribute it and/or modify it diff --git a/src/components/Header.astro b/src/components/Header.astro index 496337f..39fe35f 100644 --- a/src/components/Header.astro +++ b/src/components/Header.astro @@ -5,7 +5,8 @@ import Search from "./templates/Search.astro"; <header> <h1> - <span class="bracket"><</span><a href="/">cravodeabril.pt</a><span + <span class="bracket"><</span><a itemprop="url" href="/" + >cravodeabril.pt</a><span class="bracket" >></span> </h1> diff --git a/src/components/organisms/Date.astro b/src/components/organisms/Date.astro index c1ec7e5..960cfb7 100644 --- a/src/components/organisms/Date.astro +++ b/src/components/organisms/Date.astro @@ -1,8 +1,11 @@ --- +import type { HTMLAttributes } from "astro/types"; + interface Props { date: Date; locales: Intl.LocalesArgument; options: Intl.DateTimeFormatOptions; + itemprop: HTMLAttributes<"time">["itemprop"]; } const { date, locales, options } = Astro.props; diff --git a/src/components/organisms/KeywordsList.astro b/src/components/organisms/KeywordsList.astro index 4d4b140..d3b9e7f 100644 --- a/src/components/organisms/KeywordsList.astro +++ b/src/components/organisms/KeywordsList.astro @@ -7,7 +7,7 @@ const { keywords } = Astro.props; --- <p> - {keywords.map((x) => <span>#<b>{x}</b></span>)} + {keywords.map((x) => <span>#<b itemprop="keywords">{x}</b></span>)} </p> <style> diff --git a/src/components/templates/MicroBlog.astro b/src/components/templates/MicroBlog.astro index c16155e..88321dc 100644 --- a/src/components/templates/MicroBlog.astro +++ b/src/components/templates/MicroBlog.astro @@ -2,12 +2,9 @@ import Date from "@components/organisms/Date.astro"; import KeywordsList from "@components/organisms/KeywordsList.astro"; import { getFirstUserID, getLastUpdate } from "@lib/collection/helpers"; -import { Micro } from "@lib/collection/schemas"; -import type { CollectionEntry, z } from "astro:content"; +import type { Micro, MicroEntry } from "@lib/collection/schemas"; -interface Props extends CollectionEntry<"blog"> { - data: z.infer<typeof Micro>; -} +interface Props extends MicroEntry {} const micro = Astro.props; const { id, data, rendered } = micro; @@ -19,10 +16,14 @@ const [first, ...names] = display.split(/\s/); const last = names.length > 0 ? names[names.length - 1] : ""; const little = ((first?.[0] ?? "") + (last?.[0] ?? "")).slice(0, 2); --- -<article> +<article + itemprop="blogPost" + itemscope + itemtype="https://schema.org/BlogPosting" +> <header> <h3 class="title"> - <a href={`/blog/read/${id}`}>{title}</a> + <a href={`/blog/read/${id}`} itemprop="headline name">{title}</a> </h3> <span class="profile_picture">{ user?.website ? <a href={user.website}>{little}</a> : ( @@ -30,15 +31,21 @@ const little = ((first?.[0] ?? "") + (last?.[0] ?? "")).slice(0, 2); ) }</span> <div> - {first} {last} <small>· <Date + <span + itemprop="author" + itemscope + itemtype="https://schema.org/Person" + ><span itemprop="alternateName">{first} {last}</span></span> + <span class="small">· <Date {date} locales={lang} options={{ month: "short", day: "numeric" }} - /></small> + itemprop="dateModified" + /></span> </div> </header> <div class="content small"> - <div {lang}> + <div {lang} itemprop="articleBody text"> <Fragment set:html={rendered?.html} /> </div> <footer> diff --git a/src/components/templates/Search.astro b/src/components/templates/Search.astro index 5245643..ae80d32 100644 --- a/src/components/templates/Search.astro +++ b/src/components/templates/Search.astro @@ -2,8 +2,16 @@ const { site } = Astro; --- -<search> - <link rel="dns-prefetch" href="https://www.google.com/search"> +<search + itemprop="potentialAction" + itemscope + itemtype="https://schema.org/SearchAction" +> + <link + rel="dns-prefetch" + href="https://www.google.com/search" + itemprop="target" + > <form action="https://www.google.com/search" target="_blank" @@ -13,7 +21,7 @@ const { site } = Astro; name="search" > <details> - <summary>Pesquisar no website</summary> + <summary itemprop="description">Pesquisar no website</summary> <div class="details"> <p> <label>Barra de pesquisa <input @@ -25,6 +33,7 @@ const { site } = Astro; title={`"site:${site} " é usado para que os resultados da pesquisa fiquem restritos a este website`} pattern={`site:${site} .+`} size={`site:${site} .+`.length} + itemprop="query" /></label> </p> <p class="mute"> diff --git a/src/components/templates/SimplePostList.astro b/src/components/templates/SimplePostList.astro index c2c002b..164b32b 100644 --- a/src/components/templates/SimplePostList.astro +++ b/src/components/templates/SimplePostList.astro @@ -21,14 +21,22 @@ const { posts } = Astro.props; const display = name ?? email ?? entity; return ( <li> - <article> - <h3><a href={`/blog/read/${id}`}>{title}</a></h3> - { - description && - description.split("\n\n").map((paragraph) => ( - <p class="small">{paragraph}</p> - )) - } + <article + itemprop="blogPost" + itemscope + itemtype="https://schema.org/BlogPosting" + > + <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> <footer class="small"> <Date @@ -39,7 +47,12 @@ const { posts } = Astro.props; month: "long", day: "numeric", }} - />{display} + itemprop="dateModified" + /><span + itemprop="author" + itemscope + itemtype="https://schema.org/Person" + ><span itemprop="alternateName">{display}</span></span> <KeywordsList {keywords} /> </footer> </article> @@ -58,7 +71,7 @@ const { posts } = Astro.props; & > article { padding-inline-end: calc(var(--size-9) * 1em); - & > p:not(:first-of-type) { + & > [itemprop="abstract"] > p:not(:first-of-type) { margin-block-start: 1.5em; } diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index afee012..f03252e 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -27,13 +27,15 @@ interface Props extends ComponentProps<typeof BaseHead> { You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. --> -<html dir="ltr" lang="pt-PT"> +<html dir="ltr" lang="pt-PT" itemscope itemtype="https://schema.org/WebSite"> <head> <BaseHead {...Astro.props} /> </head> <body> <Header /> - <slot /> + <div itemscope itemtype="https://schema.org/WebPage"> + <slot /> + </div> <Footer /> <noscript>I see, a man of culture :)</noscript> </body> diff --git a/src/pages/blog/read/[...slug].astro b/src/pages/blog/read/[...slug].astro index 5b42e86..71d0929 100644 --- a/src/pages/blog/read/[...slug].astro +++ b/src/pages/blog/read/[...slug].astro @@ -1,7 +1,6 @@ --- import { type CollectionEntry, getCollection } from "astro:content"; import { render } from "astro:content"; -import BaseHead from "@components/BaseHead.astro"; import Translations from "@components/Translations.astro"; import { toIso8601Full } from "@utils/datetime"; import ReadingTime from "@components/ReadingTime.astro"; @@ -10,10 +9,17 @@ import Citations from "@components/Citations.astro"; import Signature from "@components/signature/Signature.astro"; import CopyrightNotice from "@components/CopyrightNotice.astro"; import { verifier as verifierPrototype } from "@lib/pgp/verify"; -import { getSigners } from "@lib/collection/helpers"; +import { getSigners, isTranslation } from "@lib/collection/helpers"; import { get } from "@utils/anonymous"; import Authors from "@components/signature/Authors.astro"; import { getEntry } from "astro:content"; +import Base from "@layouts/Base.astro"; +import readingTime from "reading-time"; +import type { + Entry, + MicroEntry, + OriginalEntry, +} from "@lib/collection/schemas"; export async function getStaticPaths() { const posts = await getCollection("blog"); @@ -27,11 +33,11 @@ type Props = CollectionEntry<"blog">; const post = Astro.props; -let original: CollectionEntry<"blog">; -if (post.data.kind === "translation") { - original = await getEntry( - post.data.translationOf as CollectionEntry<"blog">, - ); +let original: OriginalEntry | MicroEntry; +if (isTranslation(post)) { + original = await getEntry(post.data.translationOf) as + | OriginalEntry + | MicroEntry; if (!original) { throw new Error(`Original post not found for ${post.id}`); @@ -126,174 +132,278 @@ const { Content } = await render(post); const { lang } = post.data; const commit = await verification?.commit; + +const reading = post.body ? readingTime(post.body, {}) : undefined; +const minutes = reading === undefined + ? undefined + : Math.ceil(reading.minutes); +const estimative = reading === undefined + ? undefined + : new Intl.DurationFormat(lang, { + style: "long", + }).format({ minutes }); +const duration = minutes === undefined + ? undefined + : `PT${Math.floor(minutes / 60) > 0 ? Math.floor(minutes / 60) + "H" : ""}${ + minutes % 60 > 0 ? minutes % 60 + "M" : "" + }`; + +const getOrUndefined = (k: string) => + k in post.data ? post.data[k as keyof typeof post.data] : undefined; +const author = { + "@type": "Person", +} as const; +const contributor = post.data.signers.filter(({ role }) => + role === "co-author" +).map(() => { + return { + "@type": "Person", + } as const; +}); +const translator = post.data.signers.filter(({ role }) => + role === "translator" +).map(() => { + return { + "@type": "Person", + } as const; +}); +const JSONLD = { + "@context": "https://schema.org", + "@type": "BlogPosting", + "@id": Astro.url.href, + articleBody: post.rendered?.html ?? post.body, + abstract: getOrUndefined("description"), + alternativeHeadline: getOrUndefined("subtitle"), + author, + citation: [].map(() => { + return { + "@type": "CreativeWork", + }; + }), + contributor, + copyrightHolder: [author, ...contributor, ...translator], + // copyrightNotice: post.data.license, // WORKAROUND + copyrightYear: post.data.dateCreated.getFullYear(), + creativeWorkStatus: "Published", + dateCreated: post.data.dateCreated.toISOString(), + dateModified: "dateUpdated" in post.data + ? post.data.dateUpdated?.toISOString() + : undefined, + // datePublished: undefined, // from git commit commit date + encodingFormat: "text/html", + headline: post.data.title, + inLanguage: post.data.lang, + isAccessibleForFree: true, + isBasedOn: isTranslation(post) + ? { + "@type": "BlogPosting", + "@id": new URL(`blog/read/${post.data.translationOf}`, Astro.site).href, + } + : undefined, + keywords: original.data.keywords, + license: post.data.license, // WORKAROUND + locationCreated: { + "@type": "Place", + // XXX: getOrUndefined("locationCreated"), + }, + mentions: [].map(() => { + return { + "@type": "Thing", + }; + }), + // publication: { + // "@type": "PublicationEvent", + // }, // from git commit + // publisher: { + // "@type": "Person", + // }, // from git commit + text: post.rendered?.html ?? post.body, + timeRequired: post.body !== undefined ? duration : undefined, + translationOf: isTranslation(post) + ? { + "@type": "BlogPosting", + "@id": new URL(`blog/read/${post.data.translationOf}`, Astro.site).href, + } + : undefined, + translator, + // version: undefined // TODO + wordCount: reading?.words, + workTranslations: translations.filter(({ id }) => id !== post.id).map(( + { id }, + ) => ({ + "@type": "BlogPosting", + "@id": new URL(`blog/read/${id}`, Astro.site).href, + })), + description: getOrUndefined("description"), + name: post.data.title, + url: Astro.url.href, +} as const; --- -<html lang="pt-PT"> - <head> - <BaseHead - title={post.data.title} - description={"description" in post.data - ? post.data.description - : post.data.title} - /> - </head> - - <body> - <main> - <article - itemscope - itemtype="http://schema.org/BlogPosting" - itemid={Astro.url.href} - > - <Translations {translations} {lang} /> - <hgroup> - <h1 itemprop="headline">{post.data.title}</h1> - { - "subtitle" in post.data && ( - <p itemprop="alternativeHeadline" class="subtitle"> - {post.data.subtitle} - </p> - ) - } - </hgroup> +<Base + title={post.data.title} + description={"description" in post.data ? post.data.description : post.data.title} +> + <main> + <article + itemscope + itemtype="http://schema.org/BlogPosting" + itemid={Astro.url.href} + > + <Translations {translations} {lang} /> + <hgroup> + <h1 itemprop="headline">{post.data.title}</h1> { - "description" in post.data && post.data.description && - ( - <section itemprop="abstract"> - <h2>Resumo</h2> - { - post.data.description.split(new RegExp("\\s{2,}")) - .map(( - x, - ) => <p>{x}</p>) - } - </section> + "subtitle" in post.data && ( + <p itemprop="alternativeHeadline" class="subtitle"> + {post.data.subtitle} + </p> ) } - {verification && <Signature {lang} {verification} />} - <footer> - { - verification?.verifications && + </hgroup> + { + "description" in post.data && post.data.description && ( - <Authors - verifications={verification.verifications} - expectedSigners={signers} - commitSignerKey={commit?.signature?.signer} - /> - ) - } - <dl> - <dt>Data de criação</dt> - <dd> - <time - itemprop="dateCreated" - datetime={toIso8601Full(post.data.dateCreated)} - >{ - new Intl.DateTimeFormat([lang], {}).format( - post.data.dateCreated, - ) - }</time> - </dd> + <section itemprop="abstract"> + <h2>Resumo</h2> { - post.data.dateUpdated && ( - <dt>Última atualização</dt><dd> - <time - itemprop="dateModified" - datetime={toIso8601Full(post.data.dateUpdated)} - >{ - new Intl.DateTimeFormat([lang], {}).format( - post.data.dateUpdated, - ) - }</time> - </dd> - ) - } - { - "locationCreated" in post.data && - post.data.locationCreated && ( - <dt - itemprop="locationCreated" - itemscope - itemtype="https://schema.org/Place" - > - Local de criação - </dt><dd> - <span itemprop="name">{post.data.locationCreated}</span> - </dd> - ) + post.data.description.split(new RegExp("\\s{2,}")) + .map(( + x, + ) => <p>{x}</p>) } - </dl> - <ReadingTime body={post.body} {lang} /> - </footer> - <hr /> - <div itemprop="articleBody text"><Content /></div> - <hr /> - { - "keywords" in original.data && ( - <Keywords keywords={original.data.keywords} /> - ) - } + </section> + ) + } + {verification && <Signature {lang} {verification} />} + <footer> { - "relatedPosts" in original.data && ( - <Citations citations={original.data.relatedPosts} /> + verification?.verifications && + ( + <Authors + verifications={verification.verifications} + expectedSigners={signers} + commitSignerKey={commit?.signature?.signer} + /> ) } - <CopyrightNotice - author={signers[0]?.entity.data.websites?.[0] ?? "Anonymous"} - website={signers[0]?.entity.data.websites?.[0]} - email={signers[0]?.entity.data.websites?.[0]} - title={post.data.title} - dateCreated={post.data.dateCreated} - license={post.data.license} - /> - </article> - </main> - </body> -</html> + <dl> + <dt>Data de criação</dt> + <dd> + <time + itemprop="dateCreated" + datetime={toIso8601Full(post.data.dateCreated)} + >{ + new Intl.DateTimeFormat([lang], {}).format( + post.data.dateCreated, + ) + }</time> + </dd> + { + post.data.dateUpdated && ( + <dt>Última atualização</dt><dd> + <time + itemprop="dateModified" + datetime={toIso8601Full(post.data.dateUpdated)} + >{ + new Intl.DateTimeFormat([lang], {}).format( + post.data.dateUpdated, + ) + }</time> + </dd> + ) + } + { + "locationCreated" in post.data && + post.data.locationCreated && ( + <dt + itemprop="locationCreated" + itemscope + itemtype="https://schema.org/Place" + > + Local de criação + </dt><dd> + <span itemprop="name">{post.data.locationCreated}</span> + </dd> + ) + } + </dl> + <ReadingTime body={post.body} {lang} /> + </footer> + <hr /> + <div itemprop="articleBody text"><Content /></div> + <hr /> + { + "keywords" in original.data && ( + <Keywords keywords={original.data.keywords} /> + ) + } + { + "relatedPosts" in original.data && ( + <Citations citations={original.data.relatedPosts} /> + ) + } + <CopyrightNotice + author={signers[0]?.entity.data.websites?.[0] ?? "Anonymous"} + website={signers[0]?.entity.data.websites?.[0]} + email={signers[0]?.entity.data.websites?.[0]} + title={post.data.title} + dateCreated={post.data.dateCreated} + license={post.data.license} + /> + </article> + </main> +</Base> + +<script + type="application/ld+json" + is:inline + set:html={JSON.stringify(JSONLD)} +/> <script type="module" is:inline> hashchange(); - window.addEventListener("hashchange", hashchange); - - document.addEventListener( - "click", - function (event) { - if ( - event.target && - event.target instanceof HTMLAnchorElement && - event.target.href === location.href && - location.hash.length > 1 - ) { - requestIdleCallback(function () { - if (!event.defaultPrevented) { - hashchange(); - } - }); - } - }, - false, - ); + window.addEventListener("hashchange", hashchange); + + document.addEventListener( + "click", + function (event) { + if ( + event.target && + event.target instanceof HTMLAnchorElement && + event.target.href === location.href && + location.hash.length > 1 + ) { + requestIdleCallback(function () { + if (!event.defaultPrevented) { + hashchange(); + } + }); + } + }, + false, + ); - function hashchange() { - let hash; + function hashchange() { + let hash; - try { - hash = decodeURIComponent(location.hash.slice(1)).toLowerCase(); - } catch (e) { - return; - } + try { + hash = decodeURIComponent(location.hash.slice(1)).toLowerCase(); + } catch (e) { + return; + } - const name = "user-content-" + hash; - const target = document.getElementById(name) || - document.getElementsByName(name)[0]; + const name = "user-content-" + hash; + const target = document.getElementById(name) || + document.getElementsByName(name)[0]; - if (target) { - requestIdleCallback(function () { - target.scrollIntoView(); - }); - } + if (target) { + requestIdleCallback(function () { + target.scrollIntoView(); + }); } + } </script> <style is:inline> diff --git a/src/pages/index.astro b/src/pages/index.astro index 1c80a76..5908e66 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -23,7 +23,11 @@ const micro = await fromPosts( --- <Base title={PUBLIC_SITE_TITLE}> - <main> + <main + itemprop="mainContentOfPage" + itemscope + itemtype="https://schema.org/WebPageElement" + > <article> <h2>Viva abril!</h2> <figure> @@ -44,8 +48,13 @@ const micro = await fromPosts( </article> { (originals.length > 0 || micro) && ( - <section id="posts"> - <h2>Últimas aplicações atualizadas</h2> + <section + id="posts" + itemprop="citation" + itemscope + itemtype="http://schema.org/Blog" + > + <h2 itemprop="name description">Últimas aplicações atualizadas</h2> {micro && <div id="last-micro"><MicroBlog {...micro} /></div>} <div id="last-originals"><SimplePostList posts={originals} /></div> </section> |