Data Layer Architecture
Overview
The data layer is the foundation of the template. It reads structured data from Git-backed repositories and exposes it as typed TypeScript objects to the rest of the system.
Architecture
┌─────── ───────────────────────────────────┐
│ Content Reader API │
│ loadItems() · loadCategories() · ... │
├──────────────────────────────────────────┤
│ YAML Parser │
│ Parses .yml files → typed objects │
├──────────────────────────────────────────┤
│ File System Reader │
│ Reads from .content/ directory │
├──────────────────────────────────────────┤
│ Data Source Adapter │
│ GitAdapter · FilesystemAdapter │
├──────────────────────────────────────────┤
│ .content/ (cloned repo) │
│ .works/works.yml · data/ · categories.yml │
└──────────────────────────────────────────┘
Content Repository Structure
The .content/ directory follows the same structure as the full Next.js template:
.content/
├── .works/
│ └── works.yml # Site-wide configuration
├── categories.yml # Category definitions (flat YAML array)
│ OR categories/
│ └── categories.yml
├── tags.yml # Tag definitions (flat YAML array)
├── collections.yml # Collection definitions
├── data/ # Item data directory
│ ├── <item-slug>/
│ │ ├── <item-slug>.yml # Item data (primary)
│ │ └── <item-slug>.<lang>.yml # i18n overlay (future)
│ └── ...
├── comparisons/ # Comparison data
│ ├── <comparison-slug>/
│ │ ├── <comparison-slug>.yml # Comparison metadata
│ │ └── <comparison-slug>.md # Comparison content
│ └── ...
└── pages/ # Static pages (Markdown/MDX)
└── ...
Data Types
ItemData
/** A single directory item, parsed from .content/data/<slug>/<slug>.yml */
interface ItemData {
/** Unique identifier, derived from directory name */
id: string;
/** Display name */
name: string;
/** URL-safe slug, same as directory name */
slug: string;
/** Short description */
description: string;
/** External URL for the item */
source_url: string;
/** Category ID(s) this item belongs to */
category: string | string[];
/** Tag IDs associated with this item */
tags: string[];
/** Collection IDs this item belongs to */
collections?: string[];
/** Whether this item is featured */
featured?: boolean;
/** URL to item's icon/logo */
icon_url?: string;
/** Brand name associated with the item */
brand?: string;
/** URL to the brand's logo image */
brand_logo_url?: string;
/** Array of screenshot/image URLs */
images?: string[];
/** Publisher name for display */
publisher?: string;
/** Last update timestamp (yyyy-MM-dd HH:mm format) */
updated_at: string;
/** Approval status */
status: 'draft' | 'pending' | 'approved' | 'rejected';
/** Markdown content body */
markdown?: string;
/** Domain-specific metadata bucket for vertical templates (events, jobs, real-estate) */
meta?: Record<string, unknown>;
/** All other fields are passed through as-is */
[key: string]: unknown;
}
CategoryData
/** A category definition from categories.yml */
interface CategoryData {
/** Unique identifier (slug-style) */
id: string;
/** Display name */
name: string;
/** Optional icon URL */
icon_url?: string;
/** Optional image URL */
image_url?: string;
}
/** Category with computed item count */
interface CategoryWithCount extends CategoryData {
count: number;
}
TagData
/** A tag definition from tags.yml */
interface TagData {
/** Unique identifier */
id: string;
/** Display name */
name: string;
/** Whether this tag is active */
isActive?: boolean;
}
/** Tag with computed item count */
interface TagWithCount extends TagData {
count: number;
}
CollectionData
/** A collection definition from collections.yml */
interface CollectionData {
/** Unique identifier */
id: string;
/** URL-safe slug */
slug: string;
/** Display name */
name: string;
/** Description text */
description: string;
/** Optional icon URL */
icon_url?: string;
/** Item slugs in this collection */
items?: string[];
/** Number of items in this collection (from YAML or computed) */
item_count?: number;
/** Whether collection is active */
isActive?: boolean;
/** Creation timestamp */
created_at?: string;
/** Last update timestamp */
updated_at?: string;
}
PageData
/** A static page parsed from .content/pages/<slug>.md */
interface PageData {
/** URL-safe slug derived from filename */
slug: string;
/** Page title from frontmatter */
title: string;
/** Page description from frontmatter */
description?: string;
/** Raw markdown content (body after frontmatter) */
content: string;
/** Pass-through for additional frontmatter fields */
[key: string]: unknown;
}
ComparisonData
/** A comparison between two items */
interface ComparisonData {
id: string;
slug: string;
title: string;
item_a_slug: string;
item_b_slug: string;
item_a_name: string;
item_b_name: string;
category?: string;
summary?: string;
verdict?: string;
verdict_winner?: 'item_a' | 'item_b' | 'tie';
dimensions?: ComparisonDimension[];
generated_at?: string;
/** Source URLs referenced in the comparison */
sources?: string[];
/** Long-form markdown content (from companion .md file) */
content?: string;
}
interface ComparisonDimension {
name: string;
item_a_summary?: string;
item_b_summary?: string;
item_a_score?: number;
item_b_score?: number;
winner?: 'item_a' | 'item_b' | 'tie';
}
SiteConfig
/** Site configuration from .works/works.yml */
interface SiteConfig {
company_name: string;
item_name: string;
items_name: string;
copyright_year: number;
app_url?: string;
logo?: LogoConfig;
pagination?: PaginationConfig;
settings?: SettingsConfig;
custom_header?: NavLinkItem[];
custom_footer?: NavLinkItem[];
homepage?: HomepageConfig;
[key: string]: unknown;
}
interface LogoConfig {
logo_image?: string;
logo_image_dark?: string;
favicon?: string;
}
interface PaginationConfig {
type: 'standard' | 'infinite';
itemsPerPage: number;
}
interface SettingsConfig {
categories_enabled?: boolean;
tags_enabled?: boolean;
collections_enabled?: boolean;
comparisons_enabled?: boolean;
featured_enabled?: boolean;
}
Content Reader API
The @ever-works/core package exposes these functions:
/** Load site configuration */
function loadConfig(adapter: DataAdapter): Promise<SiteConfig>;
/** Load all approved items */
function loadItems(adapter: DataAdapter): Promise<ItemData[]>;
/** Load a single item by slug */
function loadItem(adapter: DataAdapter, slug: string): Promise<ItemData | null>;
/** Load all categories */
function loadCategories(adapter: DataAdapter): Promise<CategoryData[]>;
/** Load all tags */
function loadTags(adapter: DataAdapter): Promise<TagData[]>;
/** Load all collections */
function loadCollections(adapter: DataAdapter): Promise<CollectionData[]>;
/** Load all comparisons */
function loadComparisons(adapter: DataAdapter): Promise<ComparisonData[]>;
/** Load a single comparison by slug */
function loadComparison(adapter: DataAdapter, slug: string): Promise<ComparisonData | null>;
/** Load all static pages */
function loadPages(adapter: DataAdapter): Promise<PageData[]>;
/** Load a single page by slug */
function loadPage(adapter: DataAdapter, slug: string): Promise<PageData | null>;
Note: The loadContent() utility in packages/core/src/content-reader.ts composes these loaders together and computes CategoryWithCount[] / TagWithCount[] with item counts. Each app's src/lib/content.ts exports a getContent() wrapper that adds caching and plugin pipeline support.
Adapter Interface
Data source adapters implement this interface:
interface DataAdapter {
/** Unique adapter identifier */
readonly id: string;
/** Human-readable name */
readonly name: string;
/** Initialize the data source (e.g., clone repo) */
init(config: AdapterConfig): Promise<void>;
/** Read a file's raw contents as a UTF-8 string */
readFile(relativePath: string): Promise<string>;
/** List all files (not directories) in a directory */
listFiles(relativeDir: string): Promise<string[]>;
/** List all immediate subdirectories in a directory */
listDirectories(relativeDir: string): Promise<string[]>;
/** Check if a file or directory exists */
exists(relativePath: string): Promise<boolean>;
/** Get the resolved content root path */
getContentPath(): string;
/** Pull latest changes from remote. Returns true if content changed. */
refresh(): Promise<boolean>;
/** Get current HEAD ref for change detection (commit SHA or mtime hash) */
getHeadRef(): Promise<string | null>;
}
interface AdapterConfig {
/** Content repository URL (for git adapter) */
repository?: string;
/** Auth token (for git adapter) */
token?: string;
/** Branch name (for git adapter, default: 'main') */
branch?: string;
/** Local filesystem path (for filesystem adapter) */
localPath?: string;
/** Clone depth for git (default: 1 for shallow clone) */
cloneDepth?: number;
/** Additional adapter-specific options */
[key: string]: unknown;
}
Built-in Adapters
-
GitAdapter — Clones a git repository at build time
- Uses
isomorphic-git(pure JS, no git binary required) - Shallow clone (depth 1) into
.content/directory - Requires
DATA_REPOSITORYenv var
- Uses
-
FilesystemAdapter — Reads from a local directory
- For development: point to a local content directory
- No git operations needed
- Requires
CONTENT_PATHenv var
Build-Time Data Loading
Data is loaded via getContent() from the app's content.ts module, which uses ContentCache and loadContent() under the hood:
// In an Astro page or component:
import { getContent } from '../lib/content';
const { items, categories, tags, config } = await getContent();
getContent() calls loadContent(adapter) (from @ever-works/core) which takes a DataAdapter and returns a ContentData object containing all items, categories, tags, collections, comparisons, pages, and config.
In static mode (ENABLE_ISR=false), all content is baked into HTML at build time — content changes require a rebuild and redeploy. In ISR mode (default), content is cached with a TTL and refreshed on demand via webhooks or polling.