Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ directory:
- [**Agent Protocol**](file:///Users/giwan/Projects/blog-astro-github/agent-docs/protocol.md):
The systematic approach agents must take for every task.

## Narrative Coding Standards

This project adheres to **Narrative Coding** and **Hexagonal Architecture**.
- **Domain Core**: Business logic resides in `src/domain/`. It must be pure TS/JS, zero framework dependencies.
- **SLAP**: Single Level of Abstraction Principle. Functions should stay at one level.
- **Small Chapters**: Functions should be < 7 lines whenever possible.
- **Prose-like**: Code should read like English. Extracted predicates are preferred over complex conditionals.
- **Ports & Adapters**: Infrastructure (Astro, React, Browser APIs) belongs in Adapters that implement or call Domain Ports.

## Mandatory Reading

Before starting any task, an agent **must** read this file and `@import` it into
Expand Down
534 changes: 514 additions & 20 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"@babel/preset-env": "^7.29.5",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@playwright/test": "^1.61.0",
"@putout/processor-html": "^14.1.1",
"@tailwindcss/typography": "^0.5.19",
"@testing-library/dom": "^10.4.1",
Expand All @@ -80,7 +81,7 @@
"eslint-plugin-astro": "^1.7.0",
"http-server": "^14.1.1",
"jest": "^30.3.0",
"jest-environment-jsdom": "^30.3.0",
"jest-environment-jsdom": "^30.4.1",
"puppeteer": "^24.43.0",
"putout": "^42.5.0",
"ts-jest": "^29.4.9",
Expand Down
38 changes: 15 additions & 23 deletions src/components/articleDataHandler.mts
Original file line number Diff line number Diff line change
Expand Up @@ -2,50 +2,42 @@ import type { ArticleData } from '../types/article';
import { TIME_CONSTANTS } from '../constants/storage';

function supportsSmoothScroll(): boolean {
return 'scrollBehavior' in document.documentElement.style;
return typeof document !== 'undefined' && 'scrollBehavior' in document.documentElement.style;
}

export function articleDataHandler() {
return {
get(): ArticleData | undefined {
return window.__ARTICLE_DATA__;
},
set(data: ArticleData): ArticleData {
return window.__ARTICLE_DATA__ = data;
}
get(): ArticleData | undefined { return window.__ARTICLE_DATA__; },
set(data: ArticleData): ArticleData { return window.__ARTICLE_DATA__ = data; }
};
}

export function isBlogPage(): boolean {
return !!window.location.pathname.startsWith("/blog");
return window.location.pathname.startsWith("/blog");
}

export function isLessThanFiveMinutes(timestamp: number): boolean {
return !!(Date.now() - timestamp < TIME_CONSTANTS.FIVE_MINUTES_MS);
return (Date.now() - timestamp) < TIME_CONSTANTS.FIVE_MINUTES_MS;
}

export function windowScrollTo(scrollPosition = 0) {
window.scrollTo({
top: scrollPosition ?? 0,
behavior: 'smooth'
})
}
const getScrollAction = () => supportsSmoothScroll() ? windowScrollTo : legacyBrowserWindowScroll;

export function legacyBrowserWindowScroll(scrollPosition = 0) {
window.scrollTo(0, scrollPosition);
export function windowScrollTo(top = 0) {
window.scrollTo({ top, behavior: 'smooth' });
}

const getScrollAction = () => supportsSmoothScroll() ? windowScrollTo : legacyBrowserWindowScroll;
export function legacyBrowserWindowScroll(top = 0) {
window.scrollTo(0, top);
}

export function restoreToScrollPosition(scrollPosition: number, delay = 150) {
// Restore the scroll position with smooth transition
setTimeout(() => getScrollAction()(scrollPosition), delay); // Slightly longer delay to allow for page transition to complete
export function restoreToScrollPosition(pos: number, delay = 150) {
setTimeout(() => getScrollAction()(pos), delay);
}

export function smoothScrollToTop() {
getScrollAction()();
getScrollAction()(0);
}

export function scrollToTopOfShell() {
smoothScrollToTop();
}
}
15 changes: 15 additions & 0 deletions src/domain/accessibility/__tests__/announcements.domain.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { getFriendlyPageTitle, formatNavigationAnnouncement } from '../announcements.domain';

describe('Announcements Domain', () => {
it('gets friendly title for home', () => {
expect(getFriendlyPageTitle('/')).toBe('Home');
});

it('gets friendly title for blog post', () => {
expect(getFriendlyPageTitle('/blog/hello')).toBe('Blog Article');
});

it('formats navigation announcement', () => {
expect(formatNavigationAnnouncement('Home', 'Blog')).toBe('Navigating from Home to Blog');
});
});
29 changes: 29 additions & 0 deletions src/domain/accessibility/announcements.domain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export function formatNavigationAnnouncement(fromTitle: string, toTitle: string): string {
return `Navigating from ${fromTitle} to ${toTitle}`;
}

export function formatLoadAnnouncement(pageTitle: string): string {
return `${pageTitle} loaded`;
}

export function formatSkipAnnouncement(targetName: string): string {
return `Skipped to ${targetName}`;
}

export function getFriendlyPageTitle(path: string): string {
const titles: Record<string, string> = {
'/': 'Home',
'/blog': 'Blog',
'/tools': 'Tools',
'/about': 'About',
'/contact': 'Contact',
'/search': 'Search',
'/offline': 'Offline',
};

if (titles[path]) return titles[path];
if (path.startsWith('/blog/')) return 'Blog Article';
if (path.startsWith('/tools/')) return 'Tools Category';

return 'Page';
}
37 changes: 37 additions & 0 deletions src/domain/accessibility/focus.domain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export function isElementVisible(element: HTMLElement): boolean {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none';
}

export function getSkipTargetName(href: string): string {
const targetMap: Record<string, string> = {
'#main-content': 'main content',
'#navigation': 'navigation',
'#footer': 'footer',
'#search': 'search',
'#sidebar': 'sidebar',
};

return targetMap[href] || href.replace('#', '');
}

export function findFirstFocusable(container: HTMLElement | Document = document): HTMLElement | null {
const selectors = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
];

for (const selector of selectors) {
const elements = container.querySelectorAll<HTMLElement>(selector);
for (const el of elements) {
if (isElementVisible(el)) return el;
}
}

return null;
}
24 changes: 24 additions & 0 deletions src/domain/accessibility/preferences.domain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export interface AccessibilityPreferences {
reducedMotion: boolean;
screenReaderAnnouncements: boolean;
focusManagement: boolean;
keyboardNavigation: boolean;
}

export const DEFAULT_PREFERENCES: AccessibilityPreferences = {
reducedMotion: false,
screenReaderAnnouncements: true,
focusManagement: true,
keyboardNavigation: true,
};

export function resolvePreferences(stored: string | null, systemReducedMotion: boolean): AccessibilityPreferences {
const base = { ...DEFAULT_PREFERENCES, reducedMotion: systemReducedMotion };
if (!stored) return base;

try {
return { ...base, ...JSON.parse(stored) };
} catch {
return base;
}
}
68 changes: 68 additions & 0 deletions src/domain/blog/__tests__/article.domain.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import {
calculateStartIndex,
calculateEndIndex,
getArticleSlice,
hasMoreArticles,
isEligibleForNextPage,
getNextPageNumber
} from '../article.domain';

describe('Article Domain', () => {
describe('calculateStartIndex', () => {
it('calculates the correct start index for page 1', () => {
expect(calculateStartIndex(1, 10)).toBe(0);
});
it('calculates the correct start index for page 2', () => {
expect(calculateStartIndex(2, 10)).toBe(10);
});
});

describe('calculateEndIndex', () => {
it('calculates the correct end index when not at the end', () => {
expect(calculateEndIndex(0, 10, 50)).toBe(10);
});
it('caps the end index to the total count', () => {
expect(calculateEndIndex(40, 10, 45)).toBe(45);
});
});

describe('getArticleSlice', () => {
const articles = Array(25).fill({}).map((_, i) => ({ url: `/p${i}`, title: `P${i}`, description: '', formattedDate: '' }));

it('returns the first page of articles', () => {
const slice = getArticleSlice(articles, 1, 10);
expect(slice.length).toBe(10);
expect(slice[0].title).toBe('P0');
});

it('returns the last page of articles', () => {
const slice = getArticleSlice(articles, 3, 10);
expect(slice.length).toBe(5);
expect(slice[0].title).toBe('P20');
});
});

describe('hasMoreArticles', () => {
it('returns true if current count is less than total', () => {
expect(hasMoreArticles(100, 50)).toBe(true);
});
it('returns false if current count equals total', () => {
expect(hasMoreArticles(100, 100)).toBe(false);
});
});

describe('isEligibleForNextPage', () => {
it('returns true if current slice matches the limit', () => {
expect(isEligibleForNextPage(10, 10)).toBe(true);
});
it('returns false if current slice is less than the limit', () => {
expect(isEligibleForNextPage(5, 10)).toBe(false);
});
});

describe('getNextPageNumber', () => {
it('increments the page number', () => {
expect(getNextPageNumber(1)).toBe(2);
});
});
});
29 changes: 29 additions & 0 deletions src/domain/blog/article.domain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { Article } from '../../types/article';

export const POSTS_PER_PAGE = 10;

export function calculateStartIndex(page: number, limit: number): number {
return (page - 1) * limit;
}

export function calculateEndIndex(startIndex: number, limit: number, total: number): number {
return Math.min(startIndex + limit, total);
}

export function getArticleSlice(allArticles: Article[], page: number, limit: number): Article[] {
const start = calculateStartIndex(page, limit);
const end = calculateEndIndex(start, limit, allArticles.length);
return allArticles.slice(start, end);
}

export function hasMoreArticles(total: number, currentCount: number): boolean {
return currentCount < total;
}

export function isEligibleForNextPage(newArticlesCount: number, limit: number): boolean {
return newArticlesCount === limit;
}

export function getNextPageNumber(currentPage: number): number {
return currentPage + 1;
}
7 changes: 7 additions & 0 deletions src/domain/blog/ports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { Article } from '../../types/article';

export interface ArticleRepository {
fetchArticles(page: number, limit: number): Promise<Article[]>;
getTotalCount(): number;
getAllArticles(): Article[];
}
22 changes: 22 additions & 0 deletions src/domain/common/date.domain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export const dateOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'long',
day: 'numeric',
};

export function formatDate(date: string, options: Intl.DateTimeFormatOptions): string {
return new Date(date).toLocaleDateString('en-GB', options);
}

export function formatDateWithWeekday(date: string): string {
return formatDate(date, { ...dateOptions, weekday: 'long' });
}

export function getDateNumber(dateString: string): number {
if (typeof dateString !== 'string') throw Error('Provided date argument is not of type string');
return Number(dateString.replace(/-/g, '')) || 0;
}

export function reverseDate(date = ''): number {
return parseInt(date.split('-').reverse().join(''));
}
20 changes: 20 additions & 0 deletions src/domain/common/router.domain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { TRouter, TTarget } from '../../types/router.d.ts';

export function getActiveStyle(router: TRouter, styles: { activeLink: string }, target: TTarget): string | undefined {
const { path, routes } = normalizeTarget(target);

if (isExactMatch(router.pathname, path)) return styles.activeLink;
if (isRouteMatch(router.pathname, routes)) return styles.activeLink;

return undefined;
}

function normalizeTarget(target: TTarget): { path: string; routes: string[] } {
if (typeof target === 'string') return { path: target, routes: [] };
if (!target.path) throw Error('The path value is required when the target is an object');
return { path: target.path, routes: target.routes || [] };
}

const isExactMatch = (current: string, target: string) => current === target;
const isRouteMatch = (current: string, routes: string[]) =>
routes.some(route => current.includes(route));
Loading