feat: Migrate www to the ui components
This commit is contained in:
19 files changed
+426
-142
No files matched your search
@@ -129,9 +129,12 @@ All packages use the `@ermnvldmr/` namespace:
|
|||||||
### TypeScript Guidelines
|
### TypeScript Guidelines
|
||||||
|
|
||||||
- **Strict Mode**: All TypeScript strict checks must pass
|
- **Strict Mode**: All TypeScript strict checks must pass
|
||||||
- **Explicit Types**: Avoid `any` and prefer explicit type definitions
|
- **No 'any'**: The use of `any` is strictly prohibited. Use `unknown` if the type is truly unknown, or define proper generic constraints.
|
||||||
- **Interface First**: Use interfaces for component props and public APIs
|
- **No 'ts-ignore' or 'ts-expect-error'**: Never suppress compiler errors. If a third-party library has missing types, provide a local declaration file (.d.ts) instead.
|
||||||
- **Cross-Package Types**: Export types from packages for service consumption
|
- **No Unsafe Assertions**: Avoid `as any` or `as T` assertions. Prefer type guards, `instanceof` checks, or proper schema validation (e.g., Zod) to narrow types.
|
||||||
|
- **Explicit Types**: Avoid type inference for public APIs; prefer explicit type definitions for exports.
|
||||||
|
- **Interface First**: Use interfaces for component props and public APIs.
|
||||||
|
- **Cross-Package Types**: Export types from packages for service consumption.
|
||||||
|
|
||||||
### TSDoc Documentation Requirements
|
### TSDoc Documentation Requirements
|
||||||
|
|
||||||
|
|||||||
@@ -176,3 +176,25 @@ export const Composition: Story = {
|
|||||||
</Container>
|
</Container>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const SemanticElements: Story = {
|
||||||
|
render: () => (
|
||||||
|
<VStack gap={4}>
|
||||||
|
<Container border as="main" bg="base" padding={4} rounded="md">
|
||||||
|
Rendered as <main>
|
||||||
|
</Container>
|
||||||
|
<Container border as="article" bg="base" padding={4} rounded="md">
|
||||||
|
Rendered as <article>
|
||||||
|
</Container>
|
||||||
|
<Container border as="section" bg="base" padding={4} rounded="md">
|
||||||
|
Rendered as <section>
|
||||||
|
</Container>
|
||||||
|
<Container border as="header" bg="base" padding={4} rounded="md">
|
||||||
|
Rendered as <header>
|
||||||
|
</Container>
|
||||||
|
<Container border as="footer" bg="base" padding={4} rounded="md">
|
||||||
|
Rendered as <footer>
|
||||||
|
</Container>
|
||||||
|
</VStack>
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -25,4 +25,15 @@ describe('components/Container', () => {
|
|||||||
expect(container).toHaveClass('cursor-pointer');
|
expect(container).toHaveClass('cursor-pointer');
|
||||||
expect(container).toHaveClass('active:opacity-80');
|
expect(container).toHaveClass('active:opacity-80');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders as different HTML elements using the "as" prop', () => {
|
||||||
|
const { rerender } = render(<Container as="main">Main Content</Container>);
|
||||||
|
expect(screen.getByText('Main Content').tagName).toBe('MAIN');
|
||||||
|
|
||||||
|
rerender(<Container as="article">Article Content</Container>);
|
||||||
|
expect(screen.getByText('Article Content').tagName).toBe('ARTICLE');
|
||||||
|
|
||||||
|
rerender(<Container as="section">Section Content</Container>);
|
||||||
|
expect(screen.getByText('Section Content').tagName).toBe('SECTION');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -86,6 +86,8 @@ export interface ContainerProps extends ClassNameProps, TestIdProps {
|
|||||||
border?: boolean;
|
border?: boolean;
|
||||||
/** Press interaction handler */
|
/** Press interaction handler */
|
||||||
onPress?: PressEvents['onPress'];
|
onPress?: PressEvents['onPress'];
|
||||||
|
/** The HTML element to use for rendering */
|
||||||
|
as?: React.ElementType;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,10 +105,11 @@ export const Container = memo(function Container({
|
|||||||
shadow = false,
|
shadow = false,
|
||||||
border = false,
|
border = false,
|
||||||
onPress,
|
onPress,
|
||||||
|
as: Component = 'div',
|
||||||
className,
|
className,
|
||||||
'data-testid': testId,
|
'data-testid': testId,
|
||||||
}: ContainerProps) {
|
}: ContainerProps) {
|
||||||
const ref = React.useRef<HTMLDivElement>(null);
|
const ref = React.useRef<HTMLElement>(null);
|
||||||
const { pressProps } = usePress({
|
const { pressProps } = usePress({
|
||||||
onPress,
|
onPress,
|
||||||
isDisabled: !onPress,
|
isDisabled: !onPress,
|
||||||
@@ -176,7 +179,7 @@ export const Container = memo(function Container({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Component
|
||||||
{...pressProps}
|
{...pressProps}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -193,6 +196,6 @@ export const Container = memo(function Container({
|
|||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</Component>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -20,7 +20,22 @@ type Story = StoryObj<typeof Link>;
|
|||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
args: {
|
args: {
|
||||||
href: 'https://example.com',
|
href: 'https://example.com',
|
||||||
children: 'Standard Link',
|
children: 'Standard External Link',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Internal: Story = {
|
||||||
|
args: {
|
||||||
|
href: '/internal-page',
|
||||||
|
children: 'Internal Link',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ForcedExternal: Story = {
|
||||||
|
args: {
|
||||||
|
href: '/forced-external',
|
||||||
|
isExternal: true,
|
||||||
|
children: 'Forced External Link',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,27 @@ describe('components/Link', () => {
|
|||||||
expect(link).toHaveClass('text-[var(--rb-ring)]');
|
expect(link).toHaveClass('text-[var(--rb-ring)]');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('automatically adds target="_blank" and rel for external links', () => {
|
||||||
|
render(<Link href="https://example.com">External</Link>);
|
||||||
|
const link = screen.getByRole('link');
|
||||||
|
expect(link).toHaveAttribute('target', '_blank');
|
||||||
|
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not add target="_blank" for internal links by default', () => {
|
||||||
|
render(<Link href="/articles">Internal</Link>);
|
||||||
|
const link = screen.getByRole('link');
|
||||||
|
expect(link).not.toHaveAttribute('target');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects explicit isExternal prop', () => {
|
||||||
|
const { rerender } = render(<Link isExternal href="/internal">Force External</Link>);
|
||||||
|
expect(screen.getByRole('link')).toHaveAttribute('target', '_blank');
|
||||||
|
|
||||||
|
rerender(<Link href="https://external.com" isExternal={false}>Force Internal</Link>);
|
||||||
|
expect(screen.getByRole('link')).not.toHaveAttribute('target');
|
||||||
|
});
|
||||||
|
|
||||||
it('renders as a button/span when no href is provided but has onPress', () => {
|
it('renders as a button/span when no href is provided but has onPress', () => {
|
||||||
render(<Link onPress={() => {}}>Click me</Link>);
|
render(<Link onPress={() => {}}>Click me</Link>);
|
||||||
expect(screen.getByText('Click me')).toBeInTheDocument();
|
expect(screen.getByText('Click me')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface LinkProps extends AriaLinkOptions, ClassNameProps {
|
|||||||
href?: string;
|
href?: string;
|
||||||
/** Link content */
|
/** Link content */
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
/** Whether the link is external (opens in new tab) */
|
||||||
|
isExternal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,7 +26,7 @@ export interface LinkProps extends AriaLinkOptions, ClassNameProps {
|
|||||||
* It uses standard design system colors (--rb-ring) and provides responsive hover/focus states.
|
* It uses standard design system colors (--rb-ring) and provides responsive hover/focus states.
|
||||||
*/
|
*/
|
||||||
export const Link = memo(function Link(props: LinkProps) {
|
export const Link = memo(function Link(props: LinkProps) {
|
||||||
const { children, className, href, ...otherProps } = props;
|
const { children, className, href, isExternal, ...otherProps } = props;
|
||||||
|
|
||||||
// We use a specific ref type that useLink expects.
|
// We use a specific ref type that useLink expects.
|
||||||
// Since Link can be an 'a' or a 'span', we use HTMLElement as common denominator.
|
// Since Link can be an 'a' or a 'span', we use HTMLElement as common denominator.
|
||||||
@@ -38,8 +40,20 @@ export const Link = memo(function Link(props: LinkProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (href) {
|
if (href) {
|
||||||
|
const isAutoExternal = isExternal ?? href.startsWith('http');
|
||||||
|
const externalProps = isAutoExternal
|
||||||
|
? { target: '_blank', rel: 'noopener noreferrer' }
|
||||||
|
: {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text {...linkProps} ref={ref} as="a" className={sharedClasses} href={href}>
|
<Text
|
||||||
|
{...linkProps}
|
||||||
|
{...externalProps}
|
||||||
|
ref={ref}
|
||||||
|
as="a"
|
||||||
|
className={sharedClasses}
|
||||||
|
href={href}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export type TextColor =
|
|||||||
export interface TextProps
|
export interface TextProps
|
||||||
extends ClassNameProps,
|
extends ClassNameProps,
|
||||||
TestIdProps,
|
TestIdProps,
|
||||||
Omit<React.HTMLAttributes<HTMLElement>, 'as'> {
|
Omit<React.AllHTMLAttributes<HTMLElement>, 'as' | 'type' | 'size' | 'color'> {
|
||||||
/** Content to be rendered */
|
/** Content to be rendered */
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
/** Typography style type */
|
/** Typography style type */
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Time } from './Time';
|
||||||
|
import { VStack } from '../VStack/VStack';
|
||||||
|
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
|
const meta: Meta<typeof Time> = {
|
||||||
|
title: 'components/Time',
|
||||||
|
component: Time,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
argTypes: {
|
||||||
|
color: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['default', 'primary', 'secondary', 'tertiary', 'error', 'muted', 'inherit'],
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['s', 'm', 'l'],
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['display', 'headline', 'title', 'body', 'label'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof Time>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
date: new Date(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomFormatting: Story = {
|
||||||
|
render: () => (
|
||||||
|
<VStack gap={4}>
|
||||||
|
<Time date={new Date()} formatOptions={{ dateStyle: 'full' }} />
|
||||||
|
<Time date={new Date()} formatOptions={{ dateStyle: 'long' }} />
|
||||||
|
<Time date={new Date()} formatOptions={{ dateStyle: 'medium' }} />
|
||||||
|
<Time date={new Date()} formatOptions={{ dateStyle: 'short' }} />
|
||||||
|
</VStack>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Styled: Story = {
|
||||||
|
render: () => (
|
||||||
|
<VStack gap={4}>
|
||||||
|
<Time bold color="primary" date={new Date()} size="l" type="headline" />
|
||||||
|
<Time italic color="muted" date={new Date()} size="s" type="label" />
|
||||||
|
</VStack>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomChildren: Story = {
|
||||||
|
args: {
|
||||||
|
date: new Date(),
|
||||||
|
children: 'Today',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Time } from './Time';
|
||||||
|
import { render, screen } from '../../test-utils';
|
||||||
|
|
||||||
|
describe('components/Time', () => {
|
||||||
|
it('renders correctly with a Date object', () => {
|
||||||
|
const date = new Date('2026-02-10T00:00:00Z');
|
||||||
|
render(<Time date={date} />);
|
||||||
|
|
||||||
|
const timeElement = screen.getByText(/February 10, 2026/i);
|
||||||
|
expect(timeElement.tagName).toBe('TIME');
|
||||||
|
expect(timeElement).toHaveAttribute('datetime', date.toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders correctly with an ISO string', () => {
|
||||||
|
const dateString = '2026-02-10T00:00:00Z';
|
||||||
|
render(<Time date={dateString} />);
|
||||||
|
|
||||||
|
const timeElement = screen.getByText(/February 10, 2026/i);
|
||||||
|
expect(timeElement.tagName).toBe('TIME');
|
||||||
|
expect(timeElement).toHaveAttribute('datetime', new Date(dateString).toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects custom children', () => {
|
||||||
|
const date = new Date('2026-02-10T00:00:00Z');
|
||||||
|
render(<Time date={date}>Custom Date Display</Time>);
|
||||||
|
|
||||||
|
const timeElement = screen.getByText('Custom Date Display');
|
||||||
|
expect(timeElement).toHaveAttribute('datetime', date.toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies custom formatting options', () => {
|
||||||
|
const date = new Date('2026-02-10T00:00:00Z');
|
||||||
|
render(
|
||||||
|
<Time
|
||||||
|
date={date}
|
||||||
|
formatOptions={{ year: '2-digit', month: 'short', day: 'numeric' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Depending on locale, "Feb 10, 26" or similar
|
||||||
|
expect(screen.getByText(/Feb/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/10/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/26/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes text props correctly', () => {
|
||||||
|
const date = new Date('2026-02-10T00:00:00Z');
|
||||||
|
render(<Time bold color="primary" date={date} size="l" />);
|
||||||
|
|
||||||
|
const timeElement = screen.getByText(/February 10, 2026/i);
|
||||||
|
expect(timeElement).toHaveClass('font-bold');
|
||||||
|
expect(timeElement).toHaveClass('text-[var(--rb-primary-text)]');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { castRef, genericMemo } from '@ermnvldmr/stl';
|
||||||
|
import React, { forwardRef, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { Text } from '../Text/Text';
|
||||||
|
|
||||||
|
import type { TextProps } from '../Text/Text';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the Time component.
|
||||||
|
*/
|
||||||
|
export interface TimeProps extends Omit<TextProps, 'as' | 'children'> {
|
||||||
|
/** The date to display */
|
||||||
|
date: Date | string;
|
||||||
|
/** Intl.DateTimeFormatOptions for formatting */
|
||||||
|
formatOptions?: Intl.DateTimeFormatOptions;
|
||||||
|
/** Locale for formatting (defaults to system/en-US) */
|
||||||
|
locale?: string;
|
||||||
|
/** Content to be rendered instead of formatted date */
|
||||||
|
children?: React.ReactNode;
|
||||||
|
/** Semantic ISO date string for the HTML time element */
|
||||||
|
dateTime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A semantic time component that wraps the Text component.
|
||||||
|
*
|
||||||
|
* Time provides a consistent way to display formatted dates while
|
||||||
|
* maintaining semantic integrity using the HTML <time> element.
|
||||||
|
*/
|
||||||
|
export const Time = genericMemo(
|
||||||
|
forwardRef<HTMLElement, TimeProps>(function Time(
|
||||||
|
{ date, formatOptions, locale = 'en-US', children, dateTime, ...props },
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
const dateObj = useMemo(() => (typeof date === 'string' ? new Date(date) : date), [date]);
|
||||||
|
const isoString = useMemo(() => dateTime ?? dateObj.toISOString(), [dateTime, dateObj]);
|
||||||
|
|
||||||
|
const formattedDate = useMemo(() => {
|
||||||
|
if (children) return children;
|
||||||
|
|
||||||
|
// If formatOptions contains dateStyle or timeStyle, we must not provide
|
||||||
|
// individual components like year/month/day as it will throw an error.
|
||||||
|
const hasStyle =
|
||||||
|
formatOptions && ('dateStyle' in formatOptions || 'timeStyle' in formatOptions);
|
||||||
|
const options: Intl.DateTimeFormatOptions = hasStyle
|
||||||
|
? { ...formatOptions }
|
||||||
|
: {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
...formatOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
return dateObj.toLocaleDateString(locale, options);
|
||||||
|
}, [children, dateObj, locale, formatOptions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
ref={castRef<HTMLElement>(ref)}
|
||||||
|
as="time"
|
||||||
|
dateTime={isoString}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{formattedDate}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -14,6 +14,7 @@ export * from './Code';
|
|||||||
export * from './Blockquote';
|
export * from './Blockquote';
|
||||||
export * from './Stub/Stub';
|
export * from './Stub/Stub';
|
||||||
export * from './Image/Image';
|
export * from './Image/Image';
|
||||||
|
export * from './Time/Time';
|
||||||
|
|
||||||
export * from './Table';
|
export * from './Table';
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ export type { CodeProps, CodeBlockProps } from './Code';
|
|||||||
export type { BlockquoteProps, BlockquoteCitationProps } from './Blockquote';
|
export type { BlockquoteProps, BlockquoteCitationProps } from './Blockquote';
|
||||||
export type { StubProps } from './Stub/Stub';
|
export type { StubProps } from './Stub/Stub';
|
||||||
export type { ImageProps } from './Image/Image';
|
export type { ImageProps } from './Image/Image';
|
||||||
|
export type { TimeProps } from './Time/Time';
|
||||||
export type {
|
export type {
|
||||||
TableProps,
|
TableProps,
|
||||||
TableDensity,
|
TableDensity,
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ createdDate: 2026-01-31
|
|||||||
tags: ['test', 'general']
|
tags: ['test', 'general']
|
||||||
---
|
---
|
||||||
|
|
||||||
# Hello World
|
|
||||||
|
|
||||||
This is a test article.
|
|
||||||
|
|
||||||
## Section 1
|
## Section 1
|
||||||
|
|
||||||
Some content here.
|
Some content here.
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { Header, Paragraph, List, Blockquote, Code, CodeBlock, Container, Text } from '@ermnvldmr/ui';
|
||||||
|
|
||||||
import { ArticleLayout } from './ArticleLayout';
|
import { ArticleLayout } from './ArticleLayout';
|
||||||
|
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
@@ -21,34 +23,36 @@ const meta: Meta<typeof ArticleLayout> = {
|
|||||||
export default meta;
|
export default meta;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
type Story = StoryObj<typeof ArticleLayout>;
|
type Story = StoryObj<typeof ArticleLayout>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default story showcasing various HTML elements to verify typography.
|
* Default story showcasing various UI components to verify typography in layout.
|
||||||
*/
|
*/
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
render: (args) => (
|
render: (args) => (
|
||||||
<ArticleLayout {...args}>
|
<ArticleLayout {...args}>
|
||||||
<h2>Introduction</h2>
|
<Header level={2}>Introduction</Header>
|
||||||
<p>
|
<Paragraph>
|
||||||
Astro 5 introduces the Content Layer API, a powerful way to fetch content from any source,
|
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
|
including local files, remote APIs, and CMSs. In this article, we'll explore how to set
|
||||||
up a robust system for articles and projects.
|
up a robust system for articles and projects.
|
||||||
</p>
|
</Paragraph>
|
||||||
|
|
||||||
<blockquote>
|
<Blockquote>
|
||||||
"The best way to manage content is to not manage it at all." — Someone, probably.
|
The best way to manage content is to not manage it at all.
|
||||||
</blockquote>
|
<Blockquote.Citation>Someone, probably</Blockquote.Citation>
|
||||||
|
</Blockquote>
|
||||||
|
|
||||||
<p>
|
<Paragraph>
|
||||||
Let's look at some code. Our new <code>content.config.ts</code> uses the{' '}
|
Let's look at some code. Our new <Code>content.config.ts</Code> uses the{' '}
|
||||||
<code>glob</code> loader to pull in MDX files from outside the <code>src</code> directory:
|
<Code>glob</Code> loader to pull in MDX files from outside the <Code>src</Code> directory:
|
||||||
</p>
|
</Paragraph>
|
||||||
|
|
||||||
<pre>
|
<CodeBlock label="content.config.ts">
|
||||||
<code>{`// services/www/src/content.config.ts
|
<Code>
|
||||||
|
{`// services/www/src/content.config.ts
|
||||||
import { defineCollection, z } from 'astro:content';
|
import { defineCollection, z } from 'astro:content';
|
||||||
import { glob } from 'astro/loaders';
|
import { glob } from 'astro/loaders';
|
||||||
|
|
||||||
@@ -63,37 +67,39 @@ const articles = defineCollection({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const collections = { articles };`}</code>
|
export const collections = { articles };`}
|
||||||
</pre>
|
</Code>
|
||||||
|
</CodeBlock>
|
||||||
|
|
||||||
<h3>Why Astro 5?</h3>
|
<Header level={3}>Why Astro 5?</Header>
|
||||||
<p>
|
<Paragraph>
|
||||||
Astro's zero-JS by default approach is perfect for content-heavy sites. By moving our
|
Astro's zero-JS by default approach is perfect for content-heavy sites. By moving our
|
||||||
content outside of the <code>src</code> directory, we keep our project clean and organized.
|
content outside of the <Code>src</Code> directory, we keep our project clean and organized.
|
||||||
</p>
|
</Paragraph>
|
||||||
|
|
||||||
<ul>
|
<List spacing="s" variant="unordered">
|
||||||
<li>
|
<List.Item>
|
||||||
<strong>Better performance:</strong> Only what you need is shipped.
|
<Text bold>Better performance:</Text> Only what you need is shipped.
|
||||||
</li>
|
</List.Item>
|
||||||
<li>
|
<List.Item>
|
||||||
<strong>Improved developer experience:</strong> Fast HMR and great tooling.
|
<Text bold>Improved developer experience:</Text> Fast HMR and great tooling.
|
||||||
</li>
|
</List.Item>
|
||||||
<li>
|
<List.Item>
|
||||||
<strong>Type safety:</strong> Zod schemas validate your frontmatter.
|
<Text bold>Type safety:</Text> Zod schemas validate your frontmatter.
|
||||||
</li>
|
</List.Item>
|
||||||
</ul>
|
</List>
|
||||||
|
|
||||||
<h3>Visual Media</h3>
|
<Header level={3}>Visual Media</Header>
|
||||||
<p>
|
<Paragraph>
|
||||||
We can also include images and videos. The layout ensures they are responsive and fit the
|
We can also include images and videos. The layout ensures they are responsive and fit the
|
||||||
grid.
|
grid.
|
||||||
</p>
|
</Paragraph>
|
||||||
<div className="bg-muted flex aspect-video items-center justify-center rounded-lg border-2 border-dashed">
|
<Container border bg="muted" className="flex aspect-video items-center justify-center" rounded="lg">
|
||||||
<span className="text-muted-foreground">Placeholder for Image/Video</span>
|
<Text color="muted">Placeholder for Image/Video</Text>
|
||||||
</div>
|
</Container>
|
||||||
|
|
||||||
<p>Conclusion: It's a great time to be a web developer.</p>
|
<Paragraph>Conclusion: It's a great time to be a web developer.</Paragraph>
|
||||||
</ArticleLayout>
|
</ArticleLayout>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { cn } from '@ermnvldmr/stl';
|
import { cn } from '@ermnvldmr/stl';
|
||||||
import { VStack, HStack, Markdown } from '@ermnvldmr/ui';
|
import { VStack, HStack, Markdown, Container, Time, Header, Paragraph, Text } from '@ermnvldmr/ui';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,51 +56,49 @@ export const ArticleLayout: React.FC<ArticleLayoutProps> = ({
|
|||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const formattedDate = createdDate.toLocaleDateString('en-US', {
|
const displayDate = updatedDate ? (
|
||||||
year: 'numeric',
|
<Text color="muted" size="s" type="label">
|
||||||
month: 'long',
|
Updated <Time date={updatedDate} />
|
||||||
day: 'numeric',
|
</Text>
|
||||||
});
|
) : (
|
||||||
|
<Time color="muted" date={createdDate} size="s" type="label" />
|
||||||
const displayDate = updatedDate
|
);
|
||||||
? `Updated ${updatedDate.toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
})}`
|
|
||||||
: formattedDate;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={cn('mx-auto max-w-3xl px-6 py-12 md:py-20', className)}>
|
<Container as="article" className={cn('mx-auto max-w-3xl px-6 py-12 md:py-20', className)}>
|
||||||
<header className="pb-12">
|
<VStack as="header" className="pb-12" gap={4}>
|
||||||
<VStack gap={4}>
|
<HStack align="center" className="flex-wrap" gap={4}>
|
||||||
<HStack className="text-muted-foreground text-sm font-medium" gap={4}>
|
{displayDate}
|
||||||
<time dateTime={createdDate.toISOString()}>{displayDate}</time>
|
{tags && tags.length > 0 && (
|
||||||
{tags && tags.length > 0 && (
|
<HStack className="flex-wrap" gap={2}>
|
||||||
<HStack className="flex-wrap" gap={2}>
|
{tags.map((tag) => (
|
||||||
{tags.map((tag) => (
|
<Text
|
||||||
<span
|
key={tag}
|
||||||
key={tag}
|
className="before:text-muted-foreground/50 before:mr-0.5 before:content-['#']"
|
||||||
className="before:text-muted-foreground/50 before:mr-0.5 before:content-['#']"
|
color="muted"
|
||||||
>
|
size="s"
|
||||||
{tag}
|
>
|
||||||
</span>
|
{tag}
|
||||||
))}
|
</Text>
|
||||||
</HStack>
|
))}
|
||||||
)}
|
</HStack>
|
||||||
</HStack>
|
|
||||||
|
|
||||||
<h1 className="leading-tight">{title}</h1>
|
|
||||||
|
|
||||||
{description && (
|
|
||||||
<p className="text-muted-foreground text-l leading-relaxed italic">{description}</p>
|
|
||||||
)}
|
)}
|
||||||
</VStack>
|
</HStack>
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="article-content">
|
<Header className="leading-tight" level={1}>
|
||||||
|
{title}
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
{description && (
|
||||||
|
<Paragraph italic className="leading-relaxed" color="muted" size="l">
|
||||||
|
{description}
|
||||||
|
</Paragraph>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<Container as="section" className="article-content">
|
||||||
<Markdown>{children}</Markdown>
|
<Markdown>{children}</Markdown>
|
||||||
</section>
|
</Container>
|
||||||
</article>
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
---
|
---
|
||||||
|
import { Container, Header, Paragraph, Link, VStack, List, Time, Text } from '@ermnvldmr/ui';
|
||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
|
|
||||||
import IndexLayout from '#layouts/IndexLayout/IndexLayout.astro';
|
import IndexLayout from '#layouts/IndexLayout/IndexLayout.astro';
|
||||||
@@ -11,43 +12,45 @@ const articles = (await getCollection('articles')).sort(
|
|||||||
---
|
---
|
||||||
|
|
||||||
<IndexLayout title={`Articles | ${SITE_TITLE}`} description="Browse all articles and projects.">
|
<IndexLayout title={`Articles | ${SITE_TITLE}`} description="Browse all articles and projects.">
|
||||||
<main class="mx-auto max-w-3xl px-6 py-20">
|
<Container as="main" className="mx-auto max-w-3xl px-6 py-20">
|
||||||
<h1 class="mb-12">Articles</h1>
|
<Header level={1} className="mb-12">Articles</Header>
|
||||||
<ul class="space-y-12">
|
<List variant="plain" spacing="l">
|
||||||
{
|
{
|
||||||
articles.map((article) => (
|
articles.map((article) => (
|
||||||
<li>
|
<List.Item>
|
||||||
<a href={`/articles/${article.id}`} class="group block no-underline">
|
<Link href={`/articles/${article.id}`} className="group block no-underline!">
|
||||||
<article class="space-y-3">
|
<VStack gap={3} as="article">
|
||||||
<time class="text-muted-foreground text-sm font-medium">
|
<Time
|
||||||
{article.data.createdDate.toLocaleDateString('en-US', {
|
date={article.data.createdDate}
|
||||||
year: 'numeric',
|
color="muted"
|
||||||
month: 'long',
|
size="s"
|
||||||
day: 'numeric',
|
type="label"
|
||||||
})}
|
/>
|
||||||
</time>
|
<Header
|
||||||
<h2 class="group-hover:text-primary mt-0 border-none pb-0 text-3xl transition-colors">
|
level={2}
|
||||||
|
className="group-hover:text-primary mt-0 border-none pb-0 text-3xl transition-colors"
|
||||||
|
>
|
||||||
{article.data.title}
|
{article.data.title}
|
||||||
</h2>
|
</Header>
|
||||||
{article.data.description && (
|
{article.data.description && (
|
||||||
<p class="text-muted-foreground line-clamp-3 text-lg leading-relaxed">
|
<Paragraph color="muted" size="l" maxLines={3}>
|
||||||
{article.data.description}
|
{article.data.description}
|
||||||
</p>
|
</Paragraph>
|
||||||
)}
|
)}
|
||||||
{article.data.tags && (
|
{article.data.tags && (
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
{article.data.tags.map((tag) => (
|
{article.data.tags.map((tag) => (
|
||||||
<span class="text-muted-foreground text-xs before:content-['#']">
|
<Text color="muted" size="s" className="before:content-['#']">
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</article>
|
</VStack>
|
||||||
</a>
|
</Link>
|
||||||
</li>
|
</List.Item>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
</ul>
|
</List>
|
||||||
</main>
|
</Container>
|
||||||
</IndexLayout>
|
</IndexLayout>
|
||||||
@@ -1,30 +1,32 @@
|
|||||||
---
|
---
|
||||||
|
import { Container, Header, Paragraph, Link, VStack } from '@ermnvldmr/ui';
|
||||||
|
|
||||||
import IndexLayout from '#layouts/IndexLayout/IndexLayout.astro';
|
import IndexLayout from '#layouts/IndexLayout/IndexLayout.astro';
|
||||||
import { SITE_TITLE, SITE_DESCRIPTION } from '#shared/constants';
|
import { SITE_TITLE, SITE_DESCRIPTION } from '#shared/constants';
|
||||||
import '#global.css';
|
import '#global.css';
|
||||||
---
|
---
|
||||||
|
|
||||||
<IndexLayout title={SITE_TITLE} description={SITE_DESCRIPTION}>
|
<IndexLayout title={SITE_TITLE} description={SITE_DESCRIPTION}>
|
||||||
<main class="flex h-full w-full justify-center p-4">
|
<Container as="main" className="flex h-full w-full justify-center p-4">
|
||||||
<div>
|
<VStack gap={4}>
|
||||||
<h2>Welcome to my page!</h2>
|
<Header level={2}>Welcome to my page!</Header>
|
||||||
<p>
|
<Paragraph>
|
||||||
If you see this page, my website is successfuly installed and working.
|
If you see this page, my website is successfuly installed and working.
|
||||||
<br />
|
<br />
|
||||||
It will be further configured.
|
It will be further configured.
|
||||||
</p>
|
</Paragraph>
|
||||||
<p>
|
<Paragraph>
|
||||||
For the source code and support please refer to <a href="https://github.com/deytenit/ermnvldmr.com/"
|
For the source code and support please refer to <Link href="https://github.com/deytenit/ermnvldmr.com/"
|
||||||
>ermnvldmr.com monorepo</a
|
>ermnvldmr.com monorepo</Link
|
||||||
>.
|
>.
|
||||||
<br />
|
<br />
|
||||||
Pictures of mine are available at <a href="https://ermnvldmr.com/instagram">@deytenit</a
|
Pictures of mine are available at <Link href="https://ermnvldmr.com/instagram">@deytenit</Link
|
||||||
>.
|
>.
|
||||||
</p>
|
</Paragraph>
|
||||||
<p>
|
<Paragraph>
|
||||||
Check out my <a href="/articles">thoughts and articles on various matters</a>.
|
Check out my <Link href="/articles">thoughts and articles on various matters</Link>.
|
||||||
</p>
|
</Paragraph>
|
||||||
<p><em>Thank you for visiting!</em></p>
|
<Paragraph italic>Thank you for visiting!</Paragraph>
|
||||||
</div>
|
</VStack>
|
||||||
</main>
|
</Container>
|
||||||
</IndexLayout>
|
</IndexLayout>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
export /**
|
export /**
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
const SITE_TITLE = 'Vladimir Eremin';
|
const SITE_TITLE = 'Vladimir Eremin';
|
||||||
export /**
|
export /**
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
const SITE_DESCRIPTION = 'Thoughts and creations of mine';
|
const SITE_DESCRIPTION = 'Thoughts and creations of mine';
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Container, Header, Paragraph } from '@ermnvldmr/ui';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,9 +11,9 @@ import React from 'react';
|
|||||||
*/
|
*/
|
||||||
export const Welcome: React.FC = (): React.JSX.Element => {
|
export const Welcome: React.FC = (): React.JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 bg-gray-100 rounded-lg">
|
<Container bg="muted" padding={4} rounded="lg">
|
||||||
<h1 className="text-2xl font-bold">Welcome to WWW Service</h1>
|
<Header level={1}>Welcome to WWW Service</Header>
|
||||||
<p>This is a placeholder component for Storybook.</p>
|
<Paragraph>This is a placeholder component for Storybook.</Paragraph>
|
||||||
</div>
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
Reference in new issue
Block a user