• Blog
  • Docs
  • Pricing
  • We’re hiring!
Log inSign up
nbbaier

nbbaier

x-article-reader

Public
Like
1
x-article-reader
Home
Code
11
.claude
1
backend
6
docs
1
frontend
2
shared
2
.vtignore
AGENTS.md
CLAUDE.md
README.md
biome.json
deno.json
Environment variables
Branches
1
Pull requests
Remixes
History
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.
Sign up now
Code
/
docs
/
spec.md
Code
/
docs
/
spec.md
Search
1/26/2026
Viewing readonly version of main branch: v154
View latest version
spec.md

X Article Reader - Val Town Project Specification

Project Overview

A Val Town application that formats X.com (formerly Twitter) article-style posts in a clean, aesthetically pleasing reading experience. The application fetches content from the FXTwitter API and displays it with minimal black and white design, optimized for readability.

Core Functionality

URL Routing Pattern

  • Pattern: x.com/user/status/id → subdomain.val.run/user/status/id
  • Support: Accept both x.com and twitter.com domains
  • Normalization: Internally normalize all URLs to x.com format before processing

User Interface

  • Primary Interface: Interactive web page with URL input form
  • Homepage: Landing page with description, input form, and instructional examples showing the URL pattern
  • Example Content: Balance between showing example threads and clear instructions on how to use the service

Content Scope

  • Target Content: Article threads only (long-form thread articles)
  • Article Format: API provides article content in a single structure (not individual threaded tweets)
  • Display: Single continuous article view

Design & Aesthetics

Visual Design

  • Style: Minimal black and white design
  • Purpose: Distraction-free, focus entirely on readability and content
  • Color Palette: Pure black text on white background, no dynamic theming from API color data

Typography

  • Font Family: Modern sans-serif (system fonts for fast loading)
  • Font Stack: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif
  • Content Width: Maximum ~650px for optimal readability (65-75 characters per line)
  • Responsive: Centered content with padding on larger screens

Layout Structure

  1. Hero Image: Cover media displayed as large hero image at top
  2. Article Title: Large H1 heading below hero image
  3. Author Information: Display name + avatar image
  4. Metadata: Publication date (relative format: "2 hours ago", "yesterday" for recent; full date for older)
  5. Article Content: Full formatted content with proper spacing
  6. Footer: Link to view original on X.com

Content Rendering

Article Content Formatting

  • Rendering Style: Full formatting support
  • Supported Elements:
    • Headers (H2/H3 for header-two, etc.)
    • Unordered and ordered lists (<ul>, <ol>)
    • Bold and italic text via inline style ranges
    • Code blocks with syntax highlighting
    • Links (converted from entity ranges)
    • Paragraphs with proper spacing

Entity Processing

  • Support Level: Full entity support
  • Entity Types:
    • URLs → clickable links with appropriate attributes
    • Code blocks → properly formatted with syntax highlighting
    • Mentions → formatted appropriately
    • All entity ranges from entityMap processed correctly

Media Handling

  • Images: Full media display inline within article
  • Cover Image: Displayed as prominent hero image
  • Embedded Media: All images from content shown at full width within content container
  • Loading: Load all media immediately (no lazy loading)

Code Blocks

  • Library: Highlight.js
  • Approach: Load core library + only needed languages for minimal bundle size
  • Theme: Minimal, monochrome theme matching the black/white aesthetic
  • Features: Basic syntax highlighting without line numbers or extra UI

Technical Architecture

Val Town Configuration

  • Type: HTTP Val (fileType: "http")
  • Runtime: Deno serverless environment
  • Framework: Hono for routing and API endpoints

Routing

GET /                           → Homepage with input form
GET /:username/status/:id       → Formatted article view

Data Flow

  1. Parse URL to extract username and status ID
  2. Check SQLite cache for existing article data
  3. If not cached or expired (>24 hours), fetch from FXTwitter API
  4. Store response in SQLite cache with timestamp
  5. Parse API response and render HTML
  6. Return formatted article page

External Dependencies

  • Backend: Hono for routing
  • Frontend: Highlight.js for syntax highlighting (minimal build)
  • API: FXTwitter API (https://api.fxtwitter.com/:username/status/:id)
  • Styling: Pure CSS, no frameworks
  • JavaScript: None on article pages (pure static HTML)

SQLite Caching

Schema

CREATE TABLE IF NOT EXISTS articles ( tweet_id TEXT PRIMARY KEY, fetched_at INTEGER NOT NULL, data TEXT NOT NULL );

Cache Strategy

  • Key: Tweet status ID (unique identifier)
  • TTL: 24 hours
  • Expiration Logic: Refetch articles older than 24 hours
  • Storage Format: JSON string of complete FXTwitter API response
  • Invalidation: Time-based only (no manual invalidation)

Cache Operations

// Check cache const cached = await sqlite.execute({ sql: "SELECT data, fetched_at FROM articles WHERE tweet_id = ?", args: [tweetId] }); // Is cache valid? const cacheAge = Date.now() - cached.rows[0].fetched_at; const isExpired = cacheAge > 24 * 60 * 60 * 1000; // 24 hours // Store in cache await sqlite.execute({ sql: "INSERT OR REPLACE INTO articles (tweet_id, fetched_at, data) VALUES (?, ?, ?)", args: [tweetId, Date.now(), JSON.stringify(apiResponse)] });

API Integration

FXTwitter API

  • Endpoint: https://api.fxtwitter.com/:username/status/:id
  • Response Structure: See example at https://api.fxtwitter.com/francedot/status/2015178880215298557

Key Data Points

interface APIResponse { code: number; message: string; tweet: { url: string; id: string; author: { name: string; screen_name: string; avatar_url: string; }; article: { title: string; created_at: string; cover_media: { url: string; width: number; height: number; }; content: { blocks: Array<{ type: string; // "unstyled", "header-two", "unordered-list-item", etc. text: string; inlineStyleRanges: Array<{ offset: number; length: number; style: string; // "BOLD", "ITALIC", etc. }>; entityRanges: Array<{ offset: number; length: number; key: number; }>; }>; entityMap: { [key: string]: { type: string; // "LINK", "CODE", etc. data: any; }; }; }; }; }; }

Error Handling

Error Scenarios

  1. Invalid URL: Not a valid X.com/Twitter.com article URL
  2. Deleted Tweet: FXTwitter returns 404
  3. Private Account: FXTwitter returns access error
  4. API Failure: FXTwitter API is down or unresponsive
  5. Not an Article: Tweet exists but is not an article-style post

Error Response

  • UI: Clean, minimal error page
  • Content:
    • Clear explanation of what went wrong
    • Link to go back to original article on x.com
    • Consistent styling with main design
  • HTTP Status: Appropriate status codes (404, 500, etc.)
  • No Retry Logic: Simple failure, no automatic retries
  • Stale Cache: Do not serve expired cached content on API failure

Example Error Page Structure

<div class="error-container"> <h1>Unable to load article</h1> <p>[Contextual error message]</p> <a href="[original x.com URL]">View on X.com</a> </div>

Homepage Design

Elements

  1. Service Title/Logo: Clear branding for the service
  2. Description: Brief explanation of what the service does
  3. URL Pattern Example: Show the pattern: x.com/user/status/id → subdomain.val.run/user/status/id
  4. Input Form: URL input field with submit button
  5. Example Links: 1-2 example article URLs demonstrating the service

Form Behavior

  • Input: Text field accepting X.com or Twitter.com URLs
  • Validation: Check if URL matches expected pattern
  • Action: Submit button that redirects to formatted article path
  • Transformation: Extract username and status ID, navigate to /:username/status/:id

Example Form Flow

User pastes: https://x.com/francedot/status/2015178880215298557
Form extracts: username=francedot, id=2015178880215298557
Redirects to: /francedot/status/2015178880215298557

Article Page Structure

HTML Template

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>[Article Title] - [Author Name]</title> <!-- OpenGraph tags --> <meta property="og:title" content="[Article Title]"> <meta property="og:description" content="[Preview text or first paragraph]"> <meta property="og:image" content="[Cover media URL]"> <meta property="og:type" content="article"> <!-- Styles --> <link rel="stylesheet" href="[styles.css or inline]"> <!-- Highlight.js for syntax highlighting --> <link rel="stylesheet" href="[highlight.js theme]"> <script src="[highlight.js core + languages]"></script> </head> <body> <article class="article-container"> <img src="[cover_media.url]" alt="Cover image" class="hero-image"> <header class="article-header"> <h1 class="article-title">[Article Title]</h1> <div class="author-info"> <img src="[author.avatar_url]" alt="[author.name]" class="avatar"> <span class="author-name">[author.name]</span> </div> <time class="publish-date">[Relative date]</time> </header> <div class="article-content"> [Rendered blocks with full formatting] </div> <footer class="article-footer"> <a href="[original tweet URL]" target="_blank" rel="noopener"> View original on X.com </a> </footer> </article> </body> </html>

Content Blocks Rendering Logic

Block Type Mapping

const blockTypeMap = { 'unstyled': 'p', 'header-one': 'h1', 'header-two': 'h2', 'header-three': 'h3', 'unordered-list-item': 'li', // wrap in <ul> 'ordered-list-item': 'li', // wrap in <ol> 'atomic': 'div', // special handling for embedded content 'code-block': 'pre' // wrap in <code> with highlighting };

Inline Style Processing

function applyInlineStyles(text: string, ranges: InlineStyleRange[]): string { // Sort ranges by offset // Apply BOLD → <strong>, ITALIC → <em>, etc. // Handle overlapping ranges correctly // Return HTML string with inline formatting }

Entity Range Processing

function applyEntityRanges(text: string, ranges: EntityRange[], entityMap: EntityMap): string { // For each entity range: // - Extract entity from entityMap by key // - Apply appropriate transformation: // - LINK → <a href="...">text</a> // - CODE → <code>text</code> // - Handle other entity types as needed // Return HTML string with entities processed }

Performance Considerations

Optimization Strategy

  • Load Everything: Render complete article at once (no pagination or lazy loading)
  • Minimal Dependencies: Only essential libraries loaded
  • SQLite Cache: Reduces API calls and improves response time for repeated views
  • Static HTML: No client-side JavaScript on article pages (except Highlight.js initialization)
  • Content Width: Fixed max-width prevents excessive line length and maintains readability

Bundle Size Goals

  • Core HTML/CSS: < 10KB
  • Highlight.js: < 50KB (core + common languages only)
  • Total Initial Load: < 100KB excluding images
  • Time to First Byte: < 200ms (cached), < 1s (uncached)

Privacy & Analytics

Privacy-First Approach

  • No Tracking: No analytics or user behavior logging
  • No Cookies: No cookies set or required
  • No Third-Party Scripts: Only self-hosted or CDN resources (Highlight.js)
  • No User Data Storage: Only article content cached, no user identifiers

Logging

  • Application Logs: Server-side errors logged for debugging
  • No Access Logs: Do not log article views or user behavior
  • Error Tracking: Only log failed API requests for debugging purposes

Social Sharing & Metadata

OpenGraph Tags

<meta property="og:title" content="[Article Title]"> <meta property="og:description" content="[Article preview_text or excerpt]"> <meta property="og:image" content="[cover_media.url]"> <meta property="og:type" content="article"> <meta property="og:url" content="[Current URL]">

Page Title

<title>[Article Title] by [Author Name]</title>

Date Formatting

Relative Date Logic

function formatDate(timestamp: string): string { const date = new Date(timestamp); const now = new Date(); const diffInHours = (now - date) / (1000 * 60 * 60); if (diffInHours < 1) return "Just now"; if (diffInHours < 24) return `${Math.floor(diffInHours)} hours ago`; if (diffInHours < 48) return "Yesterday"; if (diffInHours < 168) return `${Math.floor(diffInHours / 24)} days ago`; // For older articles, show full date return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); }

Implementation Checklist

Backend

  • Set up Hono app with route handlers
  • Implement SQLite schema and caching logic
  • Create FXTwitter API client with error handling
  • Build URL parser for both x.com and twitter.com
  • Implement cache lookup and storage
  • Create article renderer (blocks → HTML)
  • Build error page template

Frontend

  • Design minimal CSS stylesheet
  • Create homepage HTML template
  • Build article page HTML template
  • Integrate Highlight.js for syntax highlighting
  • Implement inline style ranges rendering
  • Implement entity ranges rendering
  • Add OpenGraph meta tags
  • Style error pages

Testing

  • Test with various article types
  • Verify cache expiration logic
  • Test error scenarios (404, API down, etc.)
  • Validate URL parsing for both domains
  • Check rendering of all block types
  • Verify syntax highlighting works
  • Test social sharing metadata
  • Mobile responsiveness testing

Deployment

  • Deploy to Val Town
  • Verify endpoint URL
  • Test live with real X.com articles
  • Monitor for errors
  • Document usage

Future Considerations (Out of Scope for v1)

  • Dark mode toggle
  • Reader preferences (font size, width)
  • Article bookmarking/favorites
  • RSS feed generation
  • Print-optimized styling
  • Reading time estimates
  • Multi-language support
  • Search functionality
  • Article collections/categorization

Success Criteria

  1. Functionality: Successfully formats X.com article threads with proper rendering
  2. Performance: Pages load in < 2 seconds including images
  3. Readability: Clean, distraction-free reading experience
  4. Reliability: Graceful error handling for all edge cases
  5. Simplicity: Minimal dependencies, straightforward codebase
  6. Privacy: No tracking or unnecessary data collection
FeaturesVersion controlCode intelligenceCLIMCP
Use cases
TeamsAI agentsSlackGTM
DocsShowcaseTemplatesNewestTrendingAPI examplesNPM packages
PricingNewsletterBlogAboutCareers
We’re hiring!
Brandhi@val.townStatus
X (Twitter)
Discord community
GitHub discussions
YouTube channel
Bluesky
Open Source Pledge
Terms of usePrivacy policyAbuse contact
© 2026 Val Town, Inc.