render article items

This commit is contained in:
Sergey Yarkov 2023-02-21 18:53:43 +03:00
parent 43950ae134
commit 25c37af405
No known key found for this signature in database
GPG Key ID: E91CE1FABC619B6F
31 changed files with 514 additions and 127 deletions

View File

@ -311,6 +311,13 @@ export default defineConfig({
label: "Настройки веб-сайта",
file: "siteconfig.json",
fields: [
{
label: "Название сайта",
name: "name",
widget: "string",
hint: "Отображается в шапке.",
default: "yarkov.tech",
},
{
label: "Глобальный заголовок",
name: "globalTitle",

View File

@ -9,5 +9,6 @@
"telegram": "https://t.me/sergeyyarkov",
"github": "https://github.com/sergeyyarkov"
},
"ymCounter": 90034656
"ymCounter": 90034656,
"name": "yarkov.tech"
}

View File

@ -1,83 +0,0 @@
---
import type { CollectionEntry } from "astro:content";
import { URL_BLOG_PREFIX } from "@root/pages/[...blog].astro";
import { getLanguageFromURL } from "@i18n/utils";
import { createDateFormatter, createRelativeArticleUrl } from "@root/utils";
export interface Props {
entry: CollectionEntry<"blog">;
}
const pageLang = getLanguageFromURL(Astro.url.pathname);
const { entry } = Astro.props as Props;
const {
id,
data: { title, pubDate },
} = entry;
const articleLang = id.split("/")[0];
---
<article
itemprop="blogPosts"
itemscope
itemtype="http://schema.org/BlogPosting"
class="article-item"
>
<a href={`${createRelativeArticleUrl(entry, URL_BLOG_PREFIX)}`}>
<div class="flex">
<h3 itemprop="headline">{title}</h3>
{articleLang !== pageLang && <sup>{articleLang.toLocaleUpperCase()}</sup>}
</div>
<p>
<time datetime={pubDate.toISOString()}>
{createDateFormatter(pageLang).format(pubDate)}
</time>
</p>
</a>
</article>
<style lang="scss">
article {
border-radius: var(--border-radius-md);
text-decoration: none;
& a {
display: flex;
justify-content: space-between;
align-items: center;
column-gap: 10px;
color: var(--font-color);
padding: 8px 0;
& sup {
margin-left: 5px;
font-size: 0.7em;
}
& h3 {
font-size: var(--font-size-md);
font-weight: normal;
margin: 0;
}
& p {
font-size: var(--font-size-xs);
font-family: var(--font-family-monospace);
color: var(--font-color-gray);
text-transform: uppercase;
text-align: right;
white-space: nowrap;
margin-bottom: 0;
}
}
}
@media screen and (max-width: 560px) {
article {
margin-bottom: 10px;
& a {
flex-direction: column-reverse;
align-items: flex-start;
& p {
margin-bottom: 5px;
}
}
}
}
</style>

View File

@ -0,0 +1,43 @@
.article-item {
border-radius: var(--border-radius-md);
text-decoration: none;
& a {
display: flex;
justify-content: space-between;
align-items: center;
column-gap: 10px;
color: var(--font-color);
padding: 8px 0;
& sup {
margin-left: 5px;
font-size: 0.7em;
}
& h3 {
font-size: var(--font-size-md);
font-weight: normal;
margin: 0;
}
& p {
font-size: var(--font-size-xs);
font-family: var(--font-family-monospace);
color: var(--font-color-gray);
text-transform: uppercase;
text-align: right;
white-space: nowrap;
margin-bottom: 0;
}
}
}
@media screen and (max-width: 560px) {
.article-item {
margin-bottom: 10px;
& a {
flex-direction: column-reverse;
align-items: flex-start;
& p {
margin-bottom: 5px;
}
}
}
}

View File

@ -0,0 +1,42 @@
import type { Component } from "solid-js";
import { createDateFormatter, createRelativeArticleUrl } from "@root/utils";
import { URL_BLOG_PREFIX } from "@root/constants";
import "./ArticleItem.scss";
export type ArticleItemProps = {
id: string;
title: string;
pubDate: Date;
pageLang: string;
};
const ArticleItem: Component<ArticleItemProps> = (props) => {
const articleLang = props.id.split("/")[0];
const href = createRelativeArticleUrl(
{ id: props.id, title: props.title, pubDate: props.pubDate },
URL_BLOG_PREFIX
);
return (
<article
itemprop="blogPosts"
itemscope
itemtype="http://schema.org/BlogPosting"
class="article-item"
>
<a href={href}>
<div class="flex">
<h3 itemprop="headline">{props.title}</h3>
{articleLang !== props.pageLang && <sup>{articleLang.toLocaleUpperCase()}</sup>}
</div>
<p>
<time datetime={props.pubDate.toISOString()}>
{createDateFormatter(props.pageLang).format(props.pubDate)}
</time>
</p>
</a>
</article>
);
};
export default ArticleItem;

View File

@ -0,0 +1 @@
export { default } from "./ArticleItem";

View File

@ -0,0 +1,18 @@
.articles {
&-list {
&__wrapper {
margin-top: 40px;
& h2 {
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border-color);
}
}
&__empty {
color: var(--font-color-gray);
text-align: center;
user-select: none;
padding: 3rem 0;
}
}
}

View File

@ -0,0 +1,101 @@
import type { Component } from "solid-js";
import { createSignal, createEffect, For } from "solid-js";
import { search, selectedTags } from "@stores/searchStore";
import ArticleItem from "@components/ArticleItem";
import "./ArticlesList.scss";
type UiStringsType = { "articles.empty": string };
type ArticleType = {
id: string;
title: string;
pubDate: Date;
};
type ArticlesType = Record<string, ArticleType[]>;
type ArticlesListProps = {
data: { articles: ArticlesType; pageLang: string };
i18n: UiStringsType;
};
const ArticlesList: Component<ArticlesListProps> = (props) => {
const [articles, setArticles] = createSignal<ArticlesType>(props.data.articles);
/**
* Sort articles by year in descending order
*/
const sortYears = (articles: ArticlesType) => {
return Object.keys(articles)
.map(Number)
.sort((a, b) => b - a);
};
const isEmpty = () => Object.keys(articles()).length === 0;
const getLength = () => Object.values(articles()).flat().length;
createEffect(() => {
// const filtered = Object.values(articles()).flat(1)
});
createEffect(() => {
let filtered = props.data.articles;
/**
* Find articles by title
*/
if (search()) {
// filtered = findArticlesBySearch(search().trim(), filtered);
// setArticles(filtered);
console.log(search());
}
/**
* Find articles by selected tags
*/
if (selectedTags().length !== 0) {
// filtered = findArticlesByTags(selectedTags(), filtered);
// setArticles(filtered);
return;
}
/**
* Set filtered variable as default value
*/
if (!search() && selectedTags().length === 0) {
filtered = props.data.articles;
setArticles(filtered);
}
});
return (
<div itemscope itemtype="http://schema.org/Blog" class="articles-list">
{!isEmpty() ? (
<For each={sortYears(articles())}>
{(year) => (
<div class="articles-list__wrapper">
<h2>{year}</h2>
<For each={articles()[year]}>
{(a) => (
<ArticleItem
id={a.id}
title={a.title}
pubDate={a.pubDate}
pageLang={props.data.pageLang}
/>
)}
</For>
</div>
)}
</For>
) : (
<div class="articles-list__empty">
<p>{props.i18n["articles.empty"]}</p>
</div>
)}
</div>
);
};
export default ArticlesList;

View File

@ -0,0 +1 @@
export { default } from "./ArticlesList";

View File

@ -71,7 +71,7 @@ const t = useTranslation(Astro);
border: 1px solid var(--color-error);
}
& .footer {
text-align: center;
text-align: right;
}
}
@media screen and (max-width: 560px) {

View File

@ -9,7 +9,7 @@ import UIString from "./UIString.astro";
<div class="container">
<div>
<i>
<UIString key={"footer.developed"} />: <TranslatedLink to="/contacts">
<UIString key={"footer.developed"} />: <TranslatedLink to="/contacts/">
{SITE_METADATA.author}
</TranslatedLink>
&copy; {new Date().getFullYear()}

View File

@ -1,7 +1,6 @@
---
import { Icon } from "astro-icon";
import { DEFAULT_LANGUAGE, SUPPORTED_LANGUAGES } from "@root/constants";
import { URL_BLOG_PREFIX } from "@root/pages/[...blog].astro";
import { DEFAULT_LANGUAGE, SUPPORTED_LANGUAGES, URL_BLOG_PREFIX } from "@root/constants";
import { getLanguageFromURL } from "@i18n/utils";
const lang = getLanguageFromURL(Astro.url.pathname);

View File

@ -3,7 +3,7 @@ import type { CollectionEntry } from "astro:content";
import siteconfig from "siteconfig.json";
import { getLanguageFromURL } from "@i18n/utils";
import * as utils from "@utils/index";
import ArticleItem from "@components/ArticleItem.astro";
import ArticleItem from "@components/ArticleItem";
import EmptyList from "@components/EmptyList.astro";
import UIString from "@components/UIString.astro";
import TranslatedLink from "@components/TranslatedLink.astro";
@ -24,12 +24,25 @@ const isEmpty = articles.length === 0;
<h2>
<UIString key={"recentArticles.heading"} />
</h2>
<TranslatedLink to="/articles">
<TranslatedLink to="/articles/">
<UIString key={"recentArticles.all"} />
</TranslatedLink>
</div>
<div itemscope itemtype="http://schema.org/Blog">
{!isEmpty ? filtered.map((article) => <ArticleItem entry={article} />) : <EmptyList />}
{
!isEmpty ? (
filtered.map((article) => (
<ArticleItem
id={article.id}
title={article.data.title}
pubDate={article.data.pubDate}
pageLang={pageLang}
/>
))
) : (
<EmptyList />
)
}
</div>
</section>
@ -40,7 +53,7 @@ const isEmpty = articles.length === 0;
align-items: center;
column-gap: 15px;
row-gap: 5px;
flex-wrap: wrap;
article-wrap: wrap;
justify-content: space-between;
border-bottom: 1px solid var(--border-color);
padding-bottom: 10px;

View File

@ -0,0 +1,27 @@
.search-input {
position: relative;
display: flex;
align-items: center;
column-gap: 15px;
margin-bottom: 10px;
& svg {
position: absolute;
left: 8px;
fill: var(--font-color-grayer);
filter: none;
}
& input {
padding-left: 40px;
color: var(--font-color-gray);
background-color: var(--background-color-hover);
}
& span {
font-size: var(--font-size-lg);
font-weight: 500;
color: var(--font-color-gray);
min-width: 20px;
text-align: center;
}
}

View File

@ -0,0 +1,76 @@
import type { Component, JSX } from "solid-js";
import { createEffect } from "solid-js";
import { search, setSearch, count } from "@stores/searchStore";
import * as utils from "@utils/history";
import "./SearchInput.scss";
type UiStringsType = { "input.search": string };
type SearchInputProps = { i18n: UiStringsType };
const SearchInput: Component<SearchInputProps> = (props) => {
/**
* Reset state and delete `search` param from url
*/
const resetSearch = () => {
const url = utils.deleteParamFromUrl("search");
setSearch("");
history.replaceState(null, "", url);
};
/**
* Update state and set url param
*/
const onSearch: JSX.EventHandler<HTMLInputElement, InputEvent> = (e) => {
const value = e.currentTarget.value;
if (value.trim().length === 0) {
resetSearch();
return;
}
const url = utils.setParamToUrl("search", value.trim());
history.replaceState(null, "", `${location.pathname}?${url}`);
setSearch(value);
// TODO: search articles
};
/**
* Set the signal state by reading the `search` parameter from the URL.
*/
createEffect(() => {
const param = new URLSearchParams(location.search).get("search");
if (!param) {
resetSearch();
return;
}
setSearch(param);
});
return (
<div class="search-input">
<svg
width="25"
height="24"
viewBox="0 0 25 24"
xmlns="http://www.w3.org/2000/svg"
data-filter="none"
>
<path d="M10.6026 3.60001C11.8798 3.59993 13.1316 3.94283 14.2172 4.5901C15.3028 5.23736 16.179 6.16331 16.7472 7.26368C17.3154 8.36405 17.5531 9.59517 17.4334 10.8184C17.3137 12.0417 16.8415 13.2085 16.0699 14.1876L21.2427 19.164C21.4085 19.3248 21.5053 19.5399 21.5135 19.7662C21.5218 19.9926 21.4411 20.2137 21.2874 20.3853C21.1337 20.5569 20.9184 20.6665 20.6845 20.6921C20.4505 20.7178 20.2151 20.6576 20.0253 20.5236L19.9205 20.436L14.7476 15.4596C13.8802 16.0922 12.8687 16.5172 11.7968 16.6994C10.7248 16.8815 9.62324 16.8157 8.58298 16.5074C7.54272 16.199 6.59366 15.6569 5.81422 14.9259C5.03478 14.1949 4.44733 13.296 4.10042 12.3034C3.7535 11.3108 3.65708 10.2531 3.81911 9.21757C3.98114 8.18204 4.39698 7.19847 5.03226 6.34812C5.66755 5.49778 6.50404 4.80506 7.47263 4.32721C8.44122 3.84936 9.51411 3.60009 10.6026 3.60001V3.60001ZM10.6026 5.40001C9.27934 5.40001 8.01025 5.90572 7.07454 6.80589C6.13884 7.70607 5.61316 8.92697 5.61316 10.2C5.61316 11.473 6.13884 12.6939 7.07454 13.5941C8.01025 14.4943 9.27934 15 10.6026 15C11.9259 15 13.195 14.4943 14.1307 13.5941C15.0664 12.6939 15.5921 11.473 15.5921 10.2C15.5921 8.92697 15.0664 7.70607 14.1307 6.80589C13.195 5.90572 11.9259 5.40001 10.6026 5.40001Z" />
</svg>
<input
onInput={onSearch}
value={search()}
type="search"
placeholder={props.i18n["input.search"] || "Search article by title..."}
autocapitalize="off"
autocomplete="off"
/>
<span>{count()}</span>
</div>
);
};
export default SearchInput;

View File

@ -0,0 +1 @@
export { default } from "./SearchInput";

View File

@ -0,0 +1,100 @@
import type { Component, JSX } from "solid-js";
import { For, createEffect } from "solid-js";
import { selectedTags, setSelectedTags } from "@stores/searchStore";
import * as utils from "@utils/history";
type TagsListProps = { data: string[] };
const TagsList: Component<TagsListProps> = (props) => {
const isTagSelected = (tag: string) => selectedTags().includes(tag.toLocaleLowerCase());
/**
* Reset signal state and `tags` property in history state
*/
const resetSelected = () => {
const url = utils.deleteParamFromUrl("tags");
setSelectedTags([]);
history.replaceState(null, "", url);
};
const onSelect: JSX.EventHandler<HTMLLIElement, PointerEvent> = (e) => {
const tag = e.target.getAttribute("data-tag");
/**
* Delete tag from selected tags if already selected
* and update param in url
*/
if (isTagSelected(tag)) {
const updated = selectedTags().filter((selectedTag) => selectedTag !== tag);
const url = utils.setParamToUrl("tags", updated.join(","));
if (updated.length === 0) {
resetSelected();
return;
}
setSelectedTags(updated);
history.replaceState(null, "", `${location.pathname}?${url}`);
return;
}
/**
* Add selected tag to state and update url
*/
const updated = [...selectedTags(), tag];
const url = utils.setParamToUrl("tags", updated.join(","));
setSelectedTags(updated);
history.replaceState(null, "", `${location.pathname}?${url}`);
};
createEffect(() => {
let param: string | string[] = new URLSearchParams(location.search).get("tags");
if (!param) {
resetSelected();
return;
}
/* to array */
param = param.split(",");
if (param.length === 0) {
resetSelected();
return;
}
const tags: string[] = [];
for (const tag of props.data) {
const isTagExist = param.includes(tag.toLocaleLowerCase());
if (isTagExist) {
tags.push(tag.toLocaleLowerCase());
}
}
setSelectedTags(tags);
});
return (
<ul class="list-links">
<For
each={props.data}
children={(tag) => (
<li
onClick={onSelect}
class="btn-link"
data-tag={tag.toLocaleLowerCase()}
data-active={isTagSelected(tag)}
>
{tag}
</li>
)}
/>
</ul>
);
};
export default TagsList;

View File

@ -0,0 +1 @@
export { default } from "./TagsList";

View File

@ -3,9 +3,10 @@ import siteconfig from "../siteconfig.json";
export const DEFAULT_LANGUAGE: LanguageKeys = "ru";
export const IS_SITE_UNDER_CONSTRUCTION = siteconfig.isSiteUnderConstruction;
export const SUPPORTED_LANGUAGES: SupportedLanguages = ["ru", "en"];
export const URL_BLOG_PREFIX = "blog";
export const SITE_METADATA: SiteMetadataType = {
title: siteconfig.globalTitle,
name: "yarkov.tech",
name: siteconfig.name,
author: "Sergey Yarkov",
themeColor: "#1e2327",
contacts: siteconfig.contacts,

View File

@ -0,0 +1,10 @@
---
author: Сергей Ярков
title: Тестовая статьия
description: фывфы
tags:
- Digital Electronics
pubDate: 2023-02-19T10:46:31.109Z
updatedDate: 2023-02-19T10:46:31.127Z
---
123

View File

@ -6,7 +6,7 @@ const blog = defineCollection({
draft: z.boolean().nullable().optional(),
coverImage: z.string().nullable().optional(),
author: z.string(),
tags: z.string().array().optional(),
tags: z.string().array(),
description: z.string(),
pubDate: z
.string()

View File

@ -47,7 +47,7 @@ export default {
"input.nameField": "Your name",
"input.subjectField": "Message subject",
"input.messageField": "Your message...",
"input.search": "search by title or description...",
"input.search": "search publications by title...",
/* Forms */
"form.contactSuccess": "The message was submitted successfully!",
"form.contactError": "An error occurred while sending. Please try again later.",

View File

@ -48,7 +48,7 @@ export default {
"input.nameField": "Ваше имя",
"input.subjectField": "Тема сообщения",
"input.messageField": "Ваше сообщение...",
"input.search": "поиск по названию или описанию...",
"input.search": "поиск публикаций по названию...",
/* Forms */
"form.contactSuccess": "Сообщение было успешно отправлено!",
"form.contactError": "При отправке произошла ошибка. Повторите запрос позже.",

View File

@ -3,13 +3,12 @@ import { CollectionEntry, getCollection } from "astro:content";
import ArticleLayout from "@root/layouts/article.astro";
import { createRelativeArticleUrl, removeLanguageCodeFromPath } from "@root/utils";
import { getLanguageFromURL, useTranslation } from "@i18n/utils";
import { URL_BLOG_PREFIX } from "@root/constants";
import languages from "@i18n/languages";
const t = useTranslation(Astro);
const pageLang = getLanguageFromURL(Astro.url.pathname);
export const URL_BLOG_PREFIX = "blog";
/**
* Generate articles pages from `blog` collection.
* The final url will be `http://website.com/blog/2023-01-01/my-article` with default language
@ -18,7 +17,11 @@ export const URL_BLOG_PREFIX = "blog";
export async function getStaticPaths() {
const collection = await getCollection("blog", (e) => !e.data.draft);
return collection.map((article) => {
const url = createRelativeArticleUrl(article);
const {
id,
data: { title, pubDate },
} = article;
const url = createRelativeArticleUrl({ id, title, pubDate });
return {
params: { blog: url.slice(1) },
props: {
@ -44,9 +47,9 @@ const { article, availableOnLang } = Astro.props;
{t("article.availableOn")}:{" "}
{availableOnLang
.filter((a) => !a.id.startsWith(`${pageLang}/`))
.map((a) => (
<a href={`${createRelativeArticleUrl(a, URL_BLOG_PREFIX)}`}>{`${
languages[a.id.split("/")[0] as LanguageKeys]
.map(({ id, data: { title, pubDate } }) => (
<a href={`${createRelativeArticleUrl({ id, title, pubDate }, URL_BLOG_PREFIX)}`}>{`${
languages[id.split("/")[0] as LanguageKeys]
}`}</a>
))}
</div>

1
src/stores/index.ts Normal file
View File

@ -0,0 +1 @@
export * from "./searchStore";

View File

@ -0,0 +1,5 @@
import { createSignal } from "solid-js";
export const [search, setSearch] = createSignal<string>("");
export const [count, setCount] = createSignal<number>(0);
export const [selectedTags, setSelectedTags] = createSignal<string[]>([]);

View File

@ -1,10 +1,18 @@
---
import { getCollection } from "astro:content";
import { useTranslation, getLanguageFromURL } from "@i18n/utils";
import * as utils from "@utils/index";
import PageLayout from "@layouts/default.astro";
import PageHeading from "@components/PageHeading.astro";
import { useTranslation, getLanguageFromURL } from "@i18n/utils";
import ArticlesList from "@root/components/ArticlesList";
import SearchInput from "@components/SearchInput";
import TagsList from "@components/TagsList";
const t = useTranslation(Astro);
const pageLang = getLanguageFromURL(Astro.url.pathname);
const articles = await getCollection("blog", (e) => !e.data.draft);
const tags = utils.getUniqueTags(articles);
const filtered = utils.removeDuplicates(articles, pageLang);
const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
---
@ -16,15 +24,12 @@ const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
>
<PageHeading heading={t("pageTitle.articles")} description={t("pageDescription.articles")} />
<section>
...
<!-- <ArticlesList
<SearchInput i18n={{ "input.search": t("input.search") }} client:load />
<TagsList data={tags} client:load />
<ArticlesList
client:load
lang={lang}
i18n={{
"input.search": t("input.search"),
"articles.empty": t("articles.empty"),
}}
data={{ articles: utils.formatToArticleBlocks(articles), tags: [...tags] }}
/> -->
i18n={{ "articles.empty": t("articles.empty") }}
data={{ articles: utils.formatToArticleBlocks(filtered), pageLang }}
/>
</section>
</PageLayout>

View File

@ -4,6 +4,7 @@ import PageHeading from "@components/PageHeading.astro";
import ContactForm from "@components/ContactForm.astro";
import ContactsList from "@components/ContactsList.astro";
import { getLanguageFromURL, useTranslation } from "@i18n/utils";
const t = useTranslation(Astro);
const lang = getLanguageFromURL(Astro.url.pathname);
---

View File

@ -9,7 +9,7 @@ import RecentArticles from "@components/RecentArticles.astro";
import UIString from "@components/UIString.astro";
const articles = (await getCollection("blog", (e) => !e.data.draft)).sort(
(a, b) => a.data.pubDate.valueOf() - b.data.pubDate.valueOf()
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
---

View File

@ -1,13 +1,14 @@
---
import PageLayout from "@layouts/default.astro";
import { getLanguageFromURL } from "@i18n/utils";
import UIString from "@root/components/UIString.astro";
const lang = getLanguageFromURL(Astro.url.pathname);
// const t = useTranslation(Astro);
---
<PageLayout lang={lang}>
<div class="container">
<h1 style="font-size:var(--font-size-5xl);">404</h1>
<p>{"Страницу которую вы пытаетесь найти не существует."}</p>
<h1 style="font-size:var(--font-size-5xl);"><UIString key={"404.heading"} /></h1>
<p><UIString key={"404.description"} /></p>
</div>
</PageLayout>

View File

@ -7,10 +7,6 @@ import { removeLanguageCodeFromPath } from "./url";
/**
* If there is a translation of an article in the current language of the page,
* the remaining articles with other translations should be removed.
*
* @param entries Array of articles
* @param pageLang Current language on page
* @returns
*/
export function removeDuplicates(
entries: CollectionEntry<"blog">[],
@ -34,14 +30,30 @@ export function removeDuplicates(
return entries;
}
/**
* This will create a relative url based on article entry.
* `/en/blog/2023-01-01/my-first-article`
*/
export function createRelativeArticleUrl(
article: CollectionEntry<"blog">,
params: { id: string; title: string; pubDate: Date },
prefix: string = "blog"
): string {
const articleLang = article.id.split("/")[0];
const date = article.data.pubDate.toISOString().split("T")[0];
return `${articleLang !== DEFAULT_LANGUAGE ? "/" + articleLang : ""}/${prefix}/${date}/${slugify(
article.data.title,
{ lowercase: true }
)}`;
const articleLang = params.id.split("/")[0];
const date = params.pubDate.toISOString().split("T")[0];
const lang = articleLang !== DEFAULT_LANGUAGE ? "/" + articleLang : "";
return `${lang}/${prefix}/${date}/${slugify(params.title, { lowercase: true })}`;
}
export function formatToArticleBlocks(articles: CollectionEntry<"blog">[]) {
const blocks: Record<string, any[]> = {};
articles.forEach((a) => {
const year = a.data.pubDate.getFullYear();
if (!blocks[year]) blocks[year] = [];
blocks[year].push({ id: a.id, title: a.data.title, pubDate: a.data.pubDate });
});
return blocks;
}
export function getUniqueTags(articles: CollectionEntry<"blog">[]): string[] {
return Array.from(new Set(articles.map((a) => a.data.tags).flat()));
}