From 08a7f44588d25fbdd5dcda94884d38d9bb27e6a9 Mon Sep 17 00:00:00 2001 From: Vladimir Eremin Date: Sat, 31 Jan 2026 13:16:38 +0300 Subject: [PATCH] feat: Add articles in www --- services/www/.storybook/main.ts | 20 ++-- .../content/articles/2026/test-article.mdx | 19 ++++ services/www/src/content.config.ts | 18 +++ .../ArticleLayout/ArticleLayout.stories.tsx | 99 ++++++++++++++++ .../layouts/ArticleLayout/ArticleLayout.tsx | 106 ++++++++++++++++++ .../www/src/pages/articles/[...slug].astro | 35 ++++++ services/www/src/pages/articles/index.astro | 53 +++++++++ services/www/src/pages/index.astro | 9 +- 8 files changed, 348 insertions(+), 11 deletions(-) create mode 100644 services/www/content/articles/2026/test-article.mdx create mode 100644 services/www/src/content.config.ts create mode 100644 services/www/src/layouts/ArticleLayout/ArticleLayout.stories.tsx create mode 100644 services/www/src/layouts/ArticleLayout/ArticleLayout.tsx create mode 100644 services/www/src/pages/articles/[...slug].astro create mode 100644 services/www/src/pages/articles/index.astro diff --git a/services/www/.storybook/main.ts b/services/www/.storybook/main.ts index 894331b..58a224c 100644 --- a/services/www/.storybook/main.ts +++ b/services/www/.storybook/main.ts @@ -1,5 +1,7 @@ import path from 'path'; import type { StorybookConfig } from '@storybook/react-vite'; +import { mergeConfig } from 'vite'; +import tailwindcss from '@tailwindcss/vite'; const config: StorybookConfig = { stories: [ @@ -25,14 +27,16 @@ const config: StorybookConfig = { }, }, async viteFinal(config) { - config.resolve = config.resolve || {}; - config.resolve.alias = { - ...config.resolve.alias, - '#': path.resolve(__dirname, '../src'), - '@ermnvldmr/stl': path.resolve(__dirname, '../../../packages/stl/src'), - '@ermnvldmr/ui': path.resolve(__dirname, '../../../packages/ui/src'), - }; - return config; + return mergeConfig(config, { + plugins: [tailwindcss()], + resolve: { + alias: { + '#': path.resolve(__dirname, '../src'), + '@ermnvldmr/stl': path.resolve(__dirname, '../../../packages/stl/src'), + '@ermnvldmr/ui': path.resolve(__dirname, '../../../packages/ui/src'), + }, + }, + }); }, }; diff --git a/services/www/content/articles/2026/test-article.mdx b/services/www/content/articles/2026/test-article.mdx new file mode 100644 index 0000000..d588649 --- /dev/null +++ b/services/www/content/articles/2026/test-article.mdx @@ -0,0 +1,19 @@ +--- +title: "Hello World" +description: "My first article using the new content system." +createdDate: 2026-01-31 +tags: ["test", "general"] +--- + +# Hello World + +This is a test article. + +## Section 1 + +Some content here. + +```typescript +const hello = "world"; +console.log(hello); +``` diff --git a/services/www/src/content.config.ts b/services/www/src/content.config.ts new file mode 100644 index 0000000..980d5cc --- /dev/null +++ b/services/www/src/content.config.ts @@ -0,0 +1,18 @@ +import { glob } from 'astro/loaders'; +import { defineCollection, z } from 'astro:content'; + +const articles = defineCollection({ + // Load all markdown and MDX files from the content/articles directory + loader: glob({ pattern: "**/*.{md,mdx}", base: "./content/articles" }), + schema: z.object({ + title: z.string(), + description: z.string().optional(), + // Astro handles date strings in frontmatter, but we use coerce to be safe + createdDate: z.coerce.date(), + updatedDate: z.coerce.date().optional(), + tags: z.array(z.string()).optional(), + image: z.string().optional(), + }), +}); + +export const collections = { articles }; diff --git a/services/www/src/layouts/ArticleLayout/ArticleLayout.stories.tsx b/services/www/src/layouts/ArticleLayout/ArticleLayout.stories.tsx new file mode 100644 index 0000000..cdd2dba --- /dev/null +++ b/services/www/src/layouts/ArticleLayout/ArticleLayout.stories.tsx @@ -0,0 +1,99 @@ +import { ArticleLayout } from './ArticleLayout'; + +import type { Meta, StoryObj } from '@storybook/react'; + +/** + * Metadata for the ArticleLayout component stories. + */ +const meta: Meta = { + title: 'WWW/Layouts/ArticleLayout', + component: ArticleLayout, + tags: ['autodocs'], + args: { + title: 'Building a Modern Content System with Astro 5', + description: + "A deep dive into building a scalable, file-based content architecture using Astro's new Content Layer API and MDX.", + createdDate: new Date('2026-01-31'), + tags: ['engineering', 'astro', 'react'], + }, +}; + +export default meta; + +/** + * + */ +type Story = StoryObj; + +/** + * Default story showcasing various HTML elements to verify typography. + */ +export const Default: Story = { + render: (args) => ( + +

Introduction

+

+ Astro 5 introduces the Content Layer API, a powerful way to fetch content from any source, + including local files, remote APIs, and CMSs. In this article, we'll explore how to set + up a robust system for articles and projects. +

+ +
+ "The best way to manage content is to not manage it at all." — Someone, probably. +
+ +

+ Let's look at some code. Our new content.config.ts uses the{' '} + glob loader to pull in MDX files from outside the src directory: +

+ +
+{`// services/www/src/content.config.ts
+import { defineCollection, z } from 'astro:content';
+import { glob } from 'astro/loaders';
+
+const articles = defineCollection({
+  loader: glob({ pattern: "**/*.mdx", base: "./content/articles" }),
+  schema: z.object({
+    title: z.string(),
+    description: z.string().optional(),
+    createdDate: z.date(),
+    updatedDate: z.date().optional(),
+    tags: z.array(z.string()).optional(),
+  }),
+});
+
+export const collections = { articles };`}
+      
+ +

Why Astro 5?

+

+ Astro's zero-JS by default approach is perfect for content-heavy sites. By moving our + content outside of the src directory, we keep our project clean and organized. +

+ +
    +
  • + Better performance: Only what you need is shipped. +
  • +
  • + Improved developer experience: Fast HMR and great tooling. +
  • +
  • + Type safety: Zod schemas validate your frontmatter. +
  • +
+ +

Visual Media

+

+ We can also include images and videos. The layout ensures they are responsive and fit the + grid. +

+
+ Placeholder for Image/Video +
+ +

Conclusion: It's a great time to be a web developer.

+
+ ), +}; diff --git a/services/www/src/layouts/ArticleLayout/ArticleLayout.tsx b/services/www/src/layouts/ArticleLayout/ArticleLayout.tsx new file mode 100644 index 0000000..140c84b --- /dev/null +++ b/services/www/src/layouts/ArticleLayout/ArticleLayout.tsx @@ -0,0 +1,106 @@ +import { cn } from '@ermnvldmr/stl'; +import { VStack, HStack } from '@ermnvldmr/ui'; +import React from 'react'; + +/** + * Props for the ArticleLayout component. + */ +export interface ArticleLayoutProps { + /** The title of the article. */ + title: string; + /** A short description or lead paragraph for the article. */ + description?: string; + /** The date the article was created. */ + createdDate: Date; + /** The date the article was last updated. */ + updatedDate?: Date; + /** An array of tags associated with the article. */ + tags?: string[]; + /** The content of the article. */ + children: React.ReactNode; + /** Optional additional class names for the article container. */ + className?: string; +} + +/** + * ArticleLayout provides a structured container for long-form content. + * It uses the project's base typography styles and provides a hero section + * for metadata like title, date, and tags. + * + * @param props - The component props. + * @param props.title + * @param props.description + * @param props.createdDate + * @param props.updatedDate + * @param props.tags + * @param props.children + * @param props.className + * @returns A React element representing the article layout. + * @example + * ```tsx + * + *

This is my first article.

+ *
+ * ``` + */ +export const ArticleLayout: React.FC = ({ + title, + description, + createdDate, + updatedDate, + tags, + children, + className, +}) => { + const formattedDate = createdDate.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + + const displayDate = updatedDate + ? `Updated ${updatedDate.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + })}` + : formattedDate; + + return ( +
+
+ + + + {tags && tags.length > 0 && ( + + {tags.map((tag) => ( + + {tag} + + ))} + + )} + + +

{title}

+ + {description && ( +

+ {description} +

+ )} +
+
+ +
{children}
+
+ ); +}; diff --git a/services/www/src/pages/articles/[...slug].astro b/services/www/src/pages/articles/[...slug].astro new file mode 100644 index 0000000..a801b63 --- /dev/null +++ b/services/www/src/pages/articles/[...slug].astro @@ -0,0 +1,35 @@ +--- +import { getCollection, render } from 'astro:content'; + +import { ArticleLayout } from '#layouts/ArticleLayout/ArticleLayout'; +import IndexLayout from '#layouts/IndexLayout/IndexLayout.astro'; +import '#global.css'; + +/** + * Generate paths for all articles in the collection. + * The slug will include the year directory (e.g., "2026/test-article"). + */ +export async function getStaticPaths() { + const articles = await getCollection('articles'); + return articles.map((entry) => ({ + params: { slug: entry.id }, + props: { entry }, + })); +} + +const { entry } = Astro.props; +const { Content } = await render(entry); +const { title, description, createdDate, updatedDate, tags } = entry.data; +--- + + + + + + diff --git a/services/www/src/pages/articles/index.astro b/services/www/src/pages/articles/index.astro new file mode 100644 index 0000000..33beccf --- /dev/null +++ b/services/www/src/pages/articles/index.astro @@ -0,0 +1,53 @@ +--- +import { getCollection } from 'astro:content'; + +import IndexLayout from '#layouts/IndexLayout/IndexLayout.astro'; +import { SITE_TITLE } from '#shared/constants'; +import '#global.css'; + +const articles = (await getCollection('articles')).sort( + (a, b) => b.data.createdDate.valueOf() - a.data.createdDate.valueOf() +); +--- + + +
+

Articles

+ +
+
diff --git a/services/www/src/pages/index.astro b/services/www/src/pages/index.astro index 0d3f47d..1101ef3 100644 --- a/services/www/src/pages/index.astro +++ b/services/www/src/pages/index.astro @@ -14,13 +14,16 @@ import '#global.css'; It will be further configured.

- For the source code and support please refer to github.comermnvldmr.com monorepo.
- Pictures of mine are available at instagram.com@deytenit.

+

+ Check out my thoughts and articles on various matters. +

Thank you for visiting!