article page

This commit is contained in:
Sergey Yarkov 2025-11-18 15:01:27 +03:00
parent 6d9809f4e1
commit d40d8bb730
11 changed files with 7133 additions and 4811 deletions

View File

@ -1 +1,2 @@
DIRECTUS_URL=
DIRECTUS_TOKEN= DIRECTUS_TOKEN=

View File

@ -1,7 +1,6 @@
export interface Article { export interface Article {
/** @primaryKey */ /** @primaryKey */
id: number; id: number;
tags?: ArticleTag[] | string[];
/** @required */ /** @required */
translations: ArticleTranslation[] | null; translations: ArticleTranslation[] | null;
} }
@ -36,6 +35,15 @@ export interface ArticleTranslation {
/** @required */ /** @required */
views: number; views: number;
cover_image?: DirectusFile | string | null; cover_image?: DirectusFile | string | null;
/** @required */
tags: ArticleTranslationsTag[] | string[];
}
export interface ArticleTranslationsTag {
/** @primaryKey */
id: number;
article_translations_id?: ArticleTranslation | string | null;
tag_id?: Tag | string | null;
} }
export interface Global { export interface Global {
@ -43,6 +51,12 @@ export interface Global {
id: number; id: number;
/** @required */ /** @required */
title: string; title: string;
ym_counter?: number | null;
github?: string | null;
telegram?: string | null;
email?: string | null;
/** @required */
recent_articles_limit: number;
translations?: GlobalTranslation[] | null; translations?: GlobalTranslation[] | null;
} }
@ -77,8 +91,32 @@ export interface HomePageTranslation {
export interface Language { export interface Language {
/** @primaryKey */ /** @primaryKey */
code: string; code: string;
name?: string | null; /** @required */
direction?: 'ltr' | 'rtl' | null; name: string;
/** @required */
direction: 'ltr' | 'rtl';
}
export interface Project {
/** @primaryKey */
id: number;
/** @required */
translations: ProjectTranslation[] | null;
}
export interface ProjectTranslation {
/** @primaryKey */
id: number;
project_id?: Project | string | null;
languages_code?: Language | string | null;
/** @required */
title: string;
/** @required */
year: string;
/** @required */
description: string;
/** @required */
source_url: string;
} }
export interface Tag { export interface Tag {
@ -539,11 +577,14 @@ export interface Schema {
article: Article[]; article: Article[];
article_tag: ArticleTag[]; article_tag: ArticleTag[];
article_translations: ArticleTranslation[]; article_translations: ArticleTranslation[];
article_translations_tag: ArticleTranslationsTag[];
global: Global; global: Global;
global_translations: GlobalTranslation[]; global_translations: GlobalTranslation[];
home_page: HomePage; home_page: HomePage;
home_page_translations: HomePageTranslation[]; home_page_translations: HomePageTranslation[];
languages: Language[]; languages: Language[];
project: Project[];
project_translations: ProjectTranslation[];
tag: Tag[]; tag: Tag[];
directus_access: DirectusAccess[]; directus_access: DirectusAccess[];
directus_activity: DirectusActivity[]; directus_activity: DirectusActivity[];
@ -578,11 +619,14 @@ export enum CollectionNames {
article = 'article', article = 'article',
article_tag = 'article_tag', article_tag = 'article_tag',
article_translations = 'article_translations', article_translations = 'article_translations',
article_translations_tag = 'article_translations_tag',
global = 'global', global = 'global',
global_translations = 'global_translations', global_translations = 'global_translations',
home_page = 'home_page', home_page = 'home_page',
home_page_translations = 'home_page_translations', home_page_translations = 'home_page_translations',
languages = 'languages', languages = 'languages',
project = 'project',
project_translations = 'project_translations',
tag = 'tag', tag = 'tag',
directus_access = 'directus_access', directus_access = 'directus_access',
directus_activity = 'directus_activity', directus_activity = 'directus_activity',

11462
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -30,19 +30,31 @@
"@sindresorhus/slugify": "^2.2.1", "@sindresorhus/slugify": "^2.2.1",
"astro": "^5.15.8", "astro": "^5.15.8",
"astro-icon": "^1.1.5", "astro-icon": "^1.1.5",
"hast-util-select": "^6.0.4",
"hast-util-to-string": "^3.0.0", "hast-util-to-string": "^3.0.0",
"hastscript": "^8.0.0", "hastscript": "^8.0.0",
"html-escaper": "^3.0.3", "html-escaper": "^3.0.3",
"i": "^0.3.7",
"marked": "^17.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"npm": "^11.6.2",
"quicklink": "^3.0.1", "quicklink": "^3.0.1",
"rehype-autolink-headings": "^7.0.0", "rehype-autolink-headings": "^7.0.0",
"rehype-slug": "^6.0.0", "rehype-slug": "^6.0.0",
"rehype-stringify": "^10.0.1",
"remark-parse": "^11.0.0",
"remark-prism": "^1.3.6",
"remark-rehype": "^11.1.2",
"remark-toc": "^9.0.0",
"sharp": "^0.32.6", "sharp": "^0.32.6",
"solid-js": "^1.7.12", "solid-js": "^1.7.12",
"typescript": "^5.8.3" "typescript": "^5.8.3",
"unified": "^11.0.5"
}, },
"devDependencies": { "devDependencies": {
"@types/html-escaper": "^3.0.4",
"@types/mime-types": "^2.1.4", "@types/mime-types": "^2.1.4",
"@types/remark-prism": "^1.3.7",
"directus-sdk-typegen": "^0.2.1", "directus-sdk-typegen": "^0.2.1",
"sass": "^1.68.0" "sass": "^1.68.0"
} }

View File

@ -2,25 +2,22 @@ import type { Component } from "solid-js";
import { createDateFormatter, createRelativeArticleUrl } from "@root/utils"; import { createDateFormatter, createRelativeArticleUrl } from "@root/utils";
import { DEFAULT_LANGUAGE, URL_BLOG_PREFIX } from "@root/constants"; import { DEFAULT_LANGUAGE, URL_BLOG_PREFIX } from "@root/constants";
import "./ArticleItem.scss"; import "./ArticleItem.scss";
import type { Language } from "@/directus-schema"; import type { ArticleTranslation } from "@/directus-schema";
export type ArticleItemProps = { export type ArticleItemProps = {
id: number; data: ArticleTranslation;
title: string;
pubDate: string;
pageLang: string; pageLang: string;
articleLang: string | Language;
slug: string;
}; };
const ArticleItem: Component<ArticleItemProps> = (props) => { const ArticleItem: Component<ArticleItemProps> = (props) => {
const {
data: { title, pub_date: pubDate, slug, languages_code },
pageLang,
} = props;
const articleLang = const articleLang =
typeof props.articleLang === "object" ? props.articleLang.code.split("-")[0] : DEFAULT_LANGUAGE; typeof languages_code === "object" ? languages_code.code.split("-")[0] : DEFAULT_LANGUAGE;
const href = createRelativeArticleUrl( const href = createRelativeArticleUrl({ pubDate, slug, articleLang }, URL_BLOG_PREFIX);
{ pubDate: props.pubDate, slug: props.slug, articleLang },
URL_BLOG_PREFIX
);
return ( return (
<article <article
@ -31,13 +28,11 @@ const ArticleItem: Component<ArticleItemProps> = (props) => {
> >
<a href={href}> <a href={href}>
<div class="flex"> <div class="flex">
<h3 itemprop="headline">{props.title}</h3> <h3 itemprop="headline">{title}</h3>
{articleLang !== props.pageLang && <sup>{articleLang.toLocaleUpperCase()}</sup>} {articleLang !== pageLang && <sup>{articleLang.toLocaleUpperCase()}</sup>}
</div> </div>
<p> <p>
<time datetime={props.pubDate}> <time datetime={pubDate}>{createDateFormatter(pageLang).format(new Date(pubDate))}</time>
{createDateFormatter(props.pageLang).format(new Date(props.pubDate))}
</time>
</p> </p>
</a> </a>
</article> </article>

View File

@ -7,10 +7,11 @@ import ArticleComments from "@components/ArticleComments.astro";
import TranslatedLink from "@components/TranslatedLink.astro"; import TranslatedLink from "@components/TranslatedLink.astro";
import AvailableOnLangs from "@components/AvailableOnLangs.astro"; import AvailableOnLangs from "@components/AvailableOnLangs.astro";
import { useTranslation, getLanguageFromURL } from "@i18n/utils"; import { useTranslation, getLanguageFromURL } from "@i18n/utils";
import * as utils from "@utils/index"; import type { ArticleTranslation } from "@/directus-schema";
import { DIRECTUS_URL } from "astro:env/server";
export type Props = { export type Props = {
article: CollectionEntry<"blog">; article: ArticleTranslation;
availableOnLangs?: CollectionEntry<"blog">[]; availableOnLangs?: CollectionEntry<"blog">[];
lang: string; lang: string;
}; };
@ -18,12 +19,8 @@ export type Props = {
const { article, availableOnLangs } = Astro.props as Props; const { article, availableOnLangs } = Astro.props as Props;
const pageLang = getLanguageFromURL(Astro.url.pathname); const pageLang = getLanguageFromURL(Astro.url.pathname);
const canonicalUrl = new URL(Astro.url.pathname, Astro.site); const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
const { const { id, cover_image, title, author, description, tags, pub_date, slug } = article;
id,
data: { coverImage, title, author, description, tags, pubDate },
} = article;
const slug = utils.encodeString(id.split("/")[1]);
const t = useTranslation(Astro); const t = useTranslation(Astro);
--- ---
@ -54,12 +51,16 @@ const t = useTranslation(Astro);
<h1 itemprop="headline">{title}</h1> <h1 itemprop="headline">{title}</h1>
</div> </div>
<div class="published"> <div class="published">
<time itemprop="datePublished" datetime={pubDate.toString()}> <time itemprop="datePublished" datetime={pub_date}>
{createDateFormatter(pageLang).format(pubDate)}&nbsp; {createDateFormatter(pageLang).format(new Date(pub_date))}&nbsp;
</time> </time>
<span itemprop="author">{author}</span> <span itemprop="author">{author}</span>
</div> </div>
{coverImage && <ArticleCover src={coverImage} alt={title} />} {
cover_image && (
<ArticleCover src={`${DIRECTUS_URL}/assets/${cover_image.filename_disk}`} alt={title} />
)
}
<div class="description"> <div class="description">
<p> <p>
{description} {description}

View File

@ -1,5 +1,4 @@
--- ---
import siteconfig from "siteconfig.json";
import { getLanguageFromURL } from "@i18n/utils"; import { getLanguageFromURL } from "@i18n/utils";
import * as utils from "@utils/index"; import * as utils from "@utils/index";
import ArticleItem from "@components/ArticleItem"; import ArticleItem from "@components/ArticleItem";
@ -41,12 +40,9 @@ const data = await directus.query<RecentArticlesQueryResult>(`
} }
`); `);
const articles = data.article.flatMap((a) => a.translations); const articles = data.article.flatMap((a) =>
utils.removeArticleDuplicates(a.translations, pageLang)
console.log(articles); );
// const { length = siteconfig.articles.recentLimit } = Astro.props as Props;
// const filtered = utils.removeDuplicates(articles.slice(0, length), pageLang);
const isEmpty = articles.length === 0; const isEmpty = articles.length === 0;
--- ---
@ -55,26 +51,14 @@ const isEmpty = articles.length === 0;
<h2> <h2>
<UIString key={"recentArticles.heading"} /> <UIString key={"recentArticles.heading"} />
</h2> </h2>
<TranslatedLink to='/articles/'> <TranslatedLink to="/articles/">
<UIString key={"recentArticles.all"} /> <UIString key={"recentArticles.all"} />
</TranslatedLink> </TranslatedLink>
</div> </div>
<div itemscope itemtype='http://schema.org/Blog'> <div itemscope itemtype="http://schema.org/Blog">
{ {
!isEmpty ? ( !isEmpty ? (
articles.map( articles.map((article) => article && <ArticleItem data={article} pageLang={pageLang} />)
(article) =>
article && (
<ArticleItem
id={article.id}
title={article.title}
pubDate={article.pub_date}
slug={article.slug}
articleLang={article.languages_code}
pageLang={pageLang}
/>
)
)
) : ( ) : (
<EmptyList /> <EmptyList />
) )
@ -82,7 +66,7 @@ const isEmpty = articles.length === 0;
</div> </div>
</section> </section>
<style lang='scss'> <style lang="scss">
section { section {
& > div:nth-child(1) { & > div:nth-child(1) {
display: flex; display: flex;

View File

@ -8,20 +8,20 @@ import ArticleContent from "@components/ArticleContent.astro";
import ArticlePost from "@components/ArticlePost.astro"; import ArticlePost from "@components/ArticlePost.astro";
import OpenGraph from "@components/OpenGraph.astro"; import OpenGraph from "@components/OpenGraph.astro";
import TableOfContents from "@root/components/TableOfContents.astro"; import TableOfContents from "@root/components/TableOfContents.astro";
import { useTranslation } from "@root/i18n/utils"; import type { ArticleTranslation } from "@/directus-schema";
import * as utils from "@utils/article";
export interface Props { export interface Props {
lang?: string | undefined; lang?: string | undefined;
article: CollectionEntry<"blog">; article: ArticleTranslation;
availableOnLangs?: CollectionEntry<"blog">[]; availableOnLangs?: CollectionEntry<"blog">[];
headings: MarkdownHeading[]; headings: MarkdownHeading[];
} }
const canonicalUrl = new URL(Astro.url.pathname, Astro.site); const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
const { lang = DEFAULT_LANGUAGE, article, headings, availableOnLangs } = Astro.props as Props; const { lang = DEFAULT_LANGUAGE, article, headings, availableOnLangs } = Astro.props as Props;
const { const { title, description, cover_image, content } = article;
data: { title, description, coverImage }, const htmlContent = await utils.markdownToHTML(content);
} = article;
--- ---
<html lang={lang}> <html lang={lang}>
@ -32,8 +32,8 @@ const {
description={description} description={description}
type="article" type="article"
url={canonicalUrl.href} url={canonicalUrl.href}
image={coverImage image={cover_image
? { path: coverImage, width: "700", height: "400", type: coverImage } ? { path: cover_image.filename_disk, width: "700", height: "400", type: cover_image.type }
: undefined} : undefined}
/> />
<script is:inline defer src="/assets/js/load-code-highlight.js"></script> <script is:inline defer src="/assets/js/load-code-highlight.js"></script>
@ -42,10 +42,8 @@ const {
<BaseLayout> <BaseLayout>
<div class="container"> <div class="container">
<ArticlePost lang={lang} article={article} availableOnLangs={availableOnLangs}> <ArticlePost lang={lang} article={article} availableOnLangs={availableOnLangs}>
<TableOfContents headings={headings} /> <!-- <TableOfContents headings={headings} /> -->
<ArticleContent> <ArticleContent set:html={htmlContent} />
<slot />
</ArticleContent>
</ArticlePost> </ArticlePost>
</div> </div>
</BaseLayout> </BaseLayout>

View File

@ -1,50 +0,0 @@
---
import type { CollectionEntry } from "astro:content";
import { getCollection, getEntry, render } from "astro:content";
import slugify from "@sindresorhus/slugify";
import ArticleLayout from "@root/layouts/article.astro";
import { createRelativeArticleUrl, removeLanguageCodeFromPath } from "@root/utils";
/**
* Generate articles pages from `blog` collection.
* The final url will be `http://website.com/blog/2023-01-01/my-article` with default language
* and `http://website.com/{language-code}/blog/2023-01-01/my-article` with other languages.
*/
// export async function getStaticPaths() {
// const collection = await getCollection("blog", (e) => !e.data.draft);
// return collection.map((article) => {
// const {
// id,
// data: { title, pubDate },
// } = article;
// const url = createRelativeArticleUrl({ id, title, pubDate });
// return {
// params: { blog: url.slice(1) },
// props: {
// article,
// availableOnLangs: collection.filter((e) =>
// e.id.includes(removeLanguageCodeFromPath(article.id))
// ),
// },
// };
// });
// }
type Props = { article: CollectionEntry<"blog">; availableOnLangs: CollectionEntry<"blog">[] };
const { blog } = Astro.params as { blog: string };
const collection = await getCollection("blog", (e) => !e.data.draft);
const slug = blog.split("/").at(-1);
const article = collection.find(
(a) => slugify(a.slug.split("/").at(-1) || "", { lowercase: true }) === slug
);
if (article === undefined) return Astro.redirect("/404");
const { Content, headings } = await render(article);
// const { article, availableOnLangs } = Astro.props;
---
<ArticleLayout lang={article.id.split("/")[0]} article={article} headings={headings}>
<Content />
</ArticleLayout>

View File

@ -0,0 +1,91 @@
---
import type { CollectionEntry } from "astro:content";
import { getCollection, getEntry, render } from "astro:content";
import slugify from "@sindresorhus/slugify";
import ArticleLayout from "@root/layouts/article.astro";
import { createRelativeArticleUrl, removeLanguageCodeFromPath } from "@root/utils";
import directus from "@/directus";
import type { ArticleTranslation } from "@/directus-schema";
/**
* Generate articles pages from `blog` collection.
* The final url will be `http://website.com/blog/2023-01-01/my-article` with default language
* and `http://website.com/{language-code}/blog/2023-01-01/my-article` with other languages.
*/
// export async function getStaticPaths() {
// const collection = await getCollection("blog", (e) => !e.data.draft);
// return collection.map((article) => {
// const {
// id,
// data: { title, pubDate },
// } = article;
// const url = createRelativeArticleUrl({ id, title, pubDate });
// return {
// params: { blog: url.slice(1) },
// props: {
// article,
// availableOnLangs: collection.filter((e) =>
// e.id.includes(removeLanguageCodeFromPath(article.id))
// ),
// },
// };
// });
// }
type Props = { article: CollectionEntry<"blog">; availableOnLangs: CollectionEntry<"blog">[] };
const { slug } = Astro.params as { blog: string; date: string; slug: string };
const { article_translations: data } = await directus.query<{
article_translations: ArticleTranslation[];
}>(`
query Article_translations {
article_translations(
filter: {
slug: {
_eq: "${slug}"
}
}
) {
title
tags {
tag_id {
title
}
}
description
slug
content
author {
first_name
last_name
}
pub_date
views
languages_code {
code
}
cover_image {
title
filename_disk
}
}
}
`);
// const { blog } = Astro.params as { blog: string };
// const collection = await getCollection("blog", (e) => !e.data.draft);
// const slug = blog.split("/").at(-1);
// const article = collection.find(
// (a) => slugify(a.slug.split("/").at(-1) || "", { lowercase: true }) === slug
// );
if (data.length === 0) return Astro.redirect("/404");
const article = data[0];
// const { Content, headings } = await render(article);
// const { article, availableOnLangs } = Astro.props;
---
<ArticleLayout lang={article.languages_code.code} article={article} />

View File

@ -1,34 +1,39 @@
import type { MarkdownHeading } from "astro"; import type { MarkdownHeading } from "astro";
import type { CollectionEntry } from "astro:content"; import type { CollectionEntry } from "astro:content";
import { DEFAULT_LANGUAGE } from "@root/constants"; import { DEFAULT_LANGUAGE } from "@root/constants";
import slugify from "@sindresorhus/slugify"; import type { ArticleTranslation } from "@/directus-schema";
import { findDuplicates } from "./string"; import { unified } from "unified";
import { removeLanguageCodeFromPath } from "./url"; import remarkParse from "remark-parse";
import remarkPrism from "remark-prism";
import remarkToc from "remark-toc";
import remarkRehype from "remark-rehype";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import rehypeSlug from "rehype-slug";
import rehypeStringify from "rehype-stringify";
import { escape } from "html-escaper";
import { h } from "hastscript";
import { toString } from "hast-util-to-string";
import { selectAll } from "hast-util-select";
/** /**
* If there is a translation of an article in the current language of the page, * If there is a translation of an article in the current language of the page,
* the remaining articles with other translations should be removed. * the remaining articles with other translations should be removed.
*/ */
export function removeDuplicates( export function removeArticleDuplicates(
entries: CollectionEntry<"blog">[], articles: ArticleTranslation[] | null,
pageLang: LanguageKeys pageLang: LanguageKeys
): CollectionEntry<"blog">[] { ): ArticleTranslation[] {
const ids = entries.map((a) => a.id) as string[]; if (!articles) return [];
const duplicates = findDuplicates(ids.map((id) => removeLanguageCodeFromPath(id))); if (articles && articles.length >= 2) {
const pageLangArticles = articles.filter((a) => {
duplicates.forEach((id) => { return (
const toDeleteIdxs: number[] = []; typeof a.languages_code === "object" && a.languages_code.code.split("-")[0] === pageLang
if (ids.indexOf(`${pageLang}/${id}`) !== -1) { );
entries.forEach((a, i) => {
if (a.id !== `${pageLang}/${id}` && a.id.includes(id)) {
toDeleteIdxs.push(i);
}
}); });
if (pageLangArticles.length !== 0) return pageLangArticles;
return articles;
} }
toDeleteIdxs.forEach((i) => entries.splice(i, 1)); return articles;
});
return entries;
} }
/** /**
@ -81,3 +86,56 @@ export function buildToc(headings: MarkdownHeading[], maxDepth: number = 2) {
}); });
return toc; return toc;
} }
export async function markdownToHTML(content: string): Promise<string> {
const createAnchorLabel = (heading: string) => {
const node = h("span.anchor-label", escape(heading));
if (node.properties) node.properties["is:raw"] = true;
return node;
};
const file = await unified()
.use(remarkParse)
.use(remarkPrism)
.use(remarkToc)
.use(remarkRehype)
.use(rehypeSlug)
.use(rehypeAutolinkHeadings, {
properties: {
class: "anchor-link",
},
behavior: "after",
group: ({ tagName }) =>
h(`div.heading-wrapper.level-${tagName}`, {
tabIndex: -1,
}),
content: (heading) => [
h(
`span.anchor-icon`,
undefined,
h(
"svg",
{
width: 18,
height: 18,
version: 1.1,
viewBox: "0 0 16 16",
xlmns: "http://www.w3.org/2000/svg",
ariaHidden: "true",
"data-filter": "none",
},
h("path", {
fillRule: "evenodd",
fill: "currentcolor",
d: "M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z",
})
)
),
createAnchorLabel(toString(heading)),
],
})
.use(rehypeStringify)
.process(content);
return String(file);
}