From 094864d779e1b8d18e0c2fd8adad67c498e51d99 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 29 Mar 2026 12:49:05 +0700 Subject: [PATCH] feat: add chat and website updates --- packages/cli/src/commands/chat/post.ts | 6 +- packages/cli/src/commands/chat/read.ts | 7 +- packages/cli/src/commands/chat/schema.ts | 57 +++++++++- packages/cli/src/commands/chat/shared.ts | 43 +++++++ packages/cli/src/commands/chat/wait.ts | 7 +- packages/cli/src/output/render.ts | 3 + packages/cli/src/output/types.ts | 2 + packages/cli/tests/02-output.test.ts | 41 +++++++ packages/cli/tests/30-chat.test.ts | 11 +- packages/cli/tests/helpers/test-daemon.ts | 6 +- packages/server/src/client/daemon-client.ts | 2 + .../src/server/chat/chat-rpc-schemas.ts | 1 + packages/server/src/server/session.ts | 5 +- .../website/posts/drafts/draft-example.md | 7 ++ packages/website/posts/hello-world.md | 7 ++ .../website/src/components/landing-page.tsx | 71 ++---------- .../website/src/components/site-header.tsx | 73 ++++++++++++ packages/website/src/posts.ts | 82 ++++++++++++++ packages/website/src/routeTree.gen.ts | 69 ++++++++++++ packages/website/src/routes/blog.tsx | 19 ++++ packages/website/src/routes/blog/$.tsx | 40 +++++++ packages/website/src/routes/blog/index.tsx | 53 +++++++++ packages/website/src/routes/changelog.tsx | 57 +--------- packages/website/src/styles.css | 106 ++++++++++++++++++ packages/website/src/types/vite-env.d.ts | 1 + packages/website/vite.config.ts | 27 ++++- 26 files changed, 677 insertions(+), 126 deletions(-) create mode 100644 packages/website/posts/drafts/draft-example.md create mode 100644 packages/website/posts/hello-world.md create mode 100644 packages/website/src/components/site-header.tsx create mode 100644 packages/website/src/posts.ts create mode 100644 packages/website/src/routes/blog.tsx create mode 100644 packages/website/src/routes/blog/$.tsx create mode 100644 packages/website/src/routes/blog/index.tsx create mode 100644 packages/website/src/types/vite-env.d.ts diff --git a/packages/cli/src/commands/chat/post.ts b/packages/cli/src/commands/chat/post.ts index f9b39f63a..32ff0f972 100644 --- a/packages/cli/src/commands/chat/post.ts +++ b/packages/cli/src/commands/chat/post.ts @@ -1,7 +1,9 @@ import type { Command } from "commander"; import type { SingleResult } from "../../output/index.js"; import { + attachAgentNamesToMessages, connectChatClient, + resolveChatAuthorAgentId, toChatCommandError, type ChatCommandOptions, } from "./shared.js"; @@ -22,11 +24,13 @@ export async function runPostCommand( const payload = await client.postChatMessage({ room, body, + authorAgentId: resolveChatAuthorAgentId(), replyToMessageId: options.replyTo, }); + const [message] = await attachAgentNamesToMessages(client, [toChatMessageRow(payload.message!)]); return { type: "single", - data: toChatMessageRow(payload.message!), + data: message!, schema: chatMessageSchema, }; } catch (err) { diff --git a/packages/cli/src/commands/chat/read.ts b/packages/cli/src/commands/chat/read.ts index 85d689ad2..7b224ff2b 100644 --- a/packages/cli/src/commands/chat/read.ts +++ b/packages/cli/src/commands/chat/read.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import type { ListResult } from "../../output/index.js"; import { + attachAgentNamesToMessages, connectChatClient, parseSinceValue, toChatCommandError, @@ -42,9 +43,13 @@ export async function runReadCommand( since: parseSinceValue(options.since), authorAgentId: options.agent, }); + const messages = await attachAgentNamesToMessages( + client, + payload.messages.map(toChatMessageRow), + ); return { type: "list", - data: payload.messages.map(toChatMessageRow), + data: messages, schema: chatMessageSchema, }; } catch (err) { diff --git a/packages/cli/src/commands/chat/schema.ts b/packages/cli/src/commands/chat/schema.ts index 144d43d76..aa1209d1f 100644 --- a/packages/cli/src/commands/chat/schema.ts +++ b/packages/cli/src/commands/chat/schema.ts @@ -1,4 +1,5 @@ import type { OutputSchema } from "../../output/index.js"; +import type { AnyCommandResult, OutputOptions } from "../../output/index.js"; export interface ChatRoomRow { name: string; @@ -11,9 +12,11 @@ export interface ChatRoomRow { export interface ChatMessageRow { id: string; author: string; + authorName: string | null; createdAt: string; replyTo: string; - mentions: string; + mentionAgentIds: string[]; + mentionLabels: string[]; body: string; } @@ -33,13 +36,59 @@ export const chatMessageSchema: OutputSchema = { columns: [ { header: "ID", field: "id", width: 36 }, { header: "AUTHOR", field: "author", width: 16 }, + { header: "AUTHOR NAME", field: (row) => row.authorName ?? "-", width: 20 }, { header: "CREATED", field: "createdAt", width: 24 }, { header: "REPLY TO", field: "replyTo", width: 36 }, - { header: "MENTIONS", field: "mentions", width: 24 }, + { header: "MENTIONS", field: (row) => row.mentionLabels.join(", ") || "-", width: 24 }, { header: "MESSAGE", field: "body", width: 60 }, ], + renderHuman: renderChatTranscript, }; +function renderChatTranscript( + result: AnyCommandResult, + _options: OutputOptions, +): string { + const data = result.type === "list" ? result.data : [result.data]; + if (data.length === 0) { + return ""; + } + return data.map(renderChatMessageBlock).join("\n\n"); +} + +function renderChatMessageBlock(message: ChatMessageRow): string { + const authorLabel = message.authorName + ? `${message.authorName} (${message.author})` + : message.author; + const lines = [`┌─ ${authorLabel} ── ${formatTimestamp(message.createdAt)} ── [msg ${message.id}]`]; + + if (message.replyTo !== "-") { + lines.push(`│ reply-to: msg ${message.replyTo}`); + } + if (message.mentionLabels.length > 0) { + lines.push(`│ mentions: ${message.mentionLabels.join(", ")}`); + } + + lines.push("│"); + + const bodyLines = message.body.split(/\r?\n/); + for (const line of bodyLines) { + lines.push(`│ ${line}`); + } + + lines.push("│"); + lines.push("└─"); + return lines.join("\n"); +} + +function formatTimestamp(input: string): string { + const date = new Date(input); + if (Number.isNaN(date.getTime())) { + return input; + } + return date.toISOString().replace("T", " ").replace(".000Z", "Z"); +} + export function toChatRoomRow(room: { id: string; name: string; @@ -67,9 +116,11 @@ export function toChatMessageRow(message: { return { id: message.id, author: message.authorAgentId, + authorName: null, createdAt: message.createdAt, replyTo: message.replyToMessageId ?? "-", - mentions: message.mentionAgentIds.length > 0 ? message.mentionAgentIds.join(",") : "-", + mentionAgentIds: message.mentionAgentIds, + mentionLabels: message.mentionAgentIds, body: message.body, }; } diff --git a/packages/cli/src/commands/chat/shared.ts b/packages/cli/src/commands/chat/shared.ts index 3bc614de0..3254ff2b7 100644 --- a/packages/cli/src/commands/chat/shared.ts +++ b/packages/cli/src/commands/chat/shared.ts @@ -1,6 +1,7 @@ import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; import { parseDuration } from "../../utils/duration.js"; import type { CommandError, CommandOptions } from "../../output/index.js"; +import type { ChatMessageRow } from "./schema.js"; export interface ChatCommandOptions extends CommandOptions { host?: string; @@ -22,6 +23,43 @@ export async function connectChatClient(host?: string) { } } +export async function attachAgentNamesToMessages( + client: Awaited>, + messages: ChatMessageRow[], +): Promise { + const agentIds = new Set(); + for (const message of messages) { + agentIds.add(message.author); + for (const mentionId of message.mentionAgentIds) { + agentIds.add(mentionId); + } + } + + if (agentIds.size === 0) { + return messages; + } + + const payload = await client.fetchAgents({ + filter: { includeArchived: true }, + }); + const agentNames = new Map(); + for (const entry of payload.entries) { + const title = entry.agent.title?.trim(); + if (title) { + agentNames.set(entry.agent.id, title); + } + } + + return messages.map((message) => ({ + ...message, + authorName: agentNames.get(message.author) ?? null, + mentionLabels: message.mentionAgentIds.map((agentId) => { + const name = agentNames.get(agentId); + return name ? `${name} (${agentId})` : agentId; + }), + })); +} + export function toChatCommandError(code: string, action: string, err: unknown): CommandError { if (err && typeof err === "object" && "code" in err && "message" in err) { return err as CommandError; @@ -82,3 +120,8 @@ export function parseTimeoutMs(input?: string): number | undefined { throw error; } } + +export function resolveChatAuthorAgentId(): string { + const agentId = process.env.PASEO_AGENT_ID?.trim(); + return agentId && agentId.length > 0 ? agentId : "manual"; +} diff --git a/packages/cli/src/commands/chat/wait.ts b/packages/cli/src/commands/chat/wait.ts index fdaeae599..3c8d38ee6 100644 --- a/packages/cli/src/commands/chat/wait.ts +++ b/packages/cli/src/commands/chat/wait.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import type { ListResult } from "../../output/index.js"; import { + attachAgentNamesToMessages, connectChatClient, parseTimeoutMs, toChatCommandError, @@ -29,9 +30,13 @@ export async function runWaitCommand( afterMessageId, timeoutMs: parseTimeoutMs(options.timeout), }); + const messages = await attachAgentNamesToMessages( + client, + payload.messages.map(toChatMessageRow), + ); return { type: "list", - data: payload.messages.map(toChatMessageRow), + data: messages, schema: chatMessageSchema, }; } catch (err) { diff --git a/packages/cli/src/output/render.ts b/packages/cli/src/output/render.ts index b9a2c1200..f3eb9f8ae 100644 --- a/packages/cli/src/output/render.ts +++ b/packages/cli/src/output/render.ts @@ -40,6 +40,9 @@ export function render( return renderYaml(result, opts); case "table": default: + if (result.schema.renderHuman) { + return result.schema.renderHuman(result, opts); + } return renderTable(result, opts); } } diff --git a/packages/cli/src/output/types.ts b/packages/cli/src/output/types.ts index 73673ebe8..eeeead1c4 100644 --- a/packages/cli/src/output/types.ts +++ b/packages/cli/src/output/types.ts @@ -40,6 +40,8 @@ export interface OutputSchema { idField: keyof T | ((item: T) => string); /** Column definitions for table output */ columns: ColumnDef[]; + /** Optional: custom renderer for human/table output */ + renderHuman?: (result: AnyCommandResult, options: OutputOptions) => string; /** Optional: transform data before JSON/YAML output */ serialize?: (data: T) => unknown; } diff --git a/packages/cli/tests/02-output.test.ts b/packages/cli/tests/02-output.test.ts index 858a2c6fa..beaa938b2 100644 --- a/packages/cli/tests/02-output.test.ts +++ b/packages/cli/tests/02-output.test.ts @@ -29,6 +29,17 @@ interface Agent { provider: string; } +interface ChatMessage { + id: string; + author: string; + authorName: string | null; + createdAt: string; + replyTo: string; + mentionAgentIds: string[]; + mentionLabels: string[]; + body: string; +} + // Schema for agents const agentSchema: OutputSchema = { idField: "id", @@ -211,6 +222,36 @@ test("render uses table format by default", () => { assert.ok(output.includes("ID"), "Should use table format with headers"); }); +test("render uses custom human renderer when provided", () => { + const chatSchema: OutputSchema = { + idField: "id", + columns: [{ header: "ID", field: "id" }], + renderHuman: (result) => { + const data = result.type === "list" ? result.data : [result.data]; + return data.map((message) => `msg ${message.id}: ${message.body}`).join("\n"); + }, + }; + const chatResult: ListResult = { + type: "list", + data: [ + { + id: "m1", + author: "agent-1", + authorName: "Planner", + createdAt: "2026-03-29T10:00:00Z", + replyTo: "-", + mentionAgentIds: [], + mentionLabels: [], + body: "hello", + }, + ], + schema: chatSchema, + }; + + const output = render(chatResult, { noColor: true }); + assert.strictEqual(output, "msg m1: hello", "Should use custom human renderer"); +}); + test("render uses json format when specified", () => { const output = render(listResult, { format: "json" }); const parsed = JSON.parse(output); diff --git a/packages/cli/tests/30-chat.test.ts b/packages/cli/tests/30-chat.test.ts index 441cbda49..11df5a603 100644 --- a/packages/cli/tests/30-chat.test.ts +++ b/packages/cli/tests/30-chat.test.ts @@ -32,13 +32,22 @@ try { "post", "coord-room", "first message for @agent-1", - ]); + ], { + env: { PASEO_AGENT_ID: "00000000-0000-4000-8000-000000000111" }, + }); assert.strictEqual(posted.exitCode, 0, posted.stderr); assert(posted.stdout.includes("first message"), posted.stdout); + assert(posted.stdout.includes("00000000-0000-4000-8000-000000000111"), posted.stdout); const read = await ctx.paseo(["chat", "read", "coord-room", "--limit", "10"]); assert.strictEqual(read.exitCode, 0, read.stderr); assert(read.stdout.includes("first message"), read.stdout); + assert(read.stdout.includes("00000000-0000-4000-8000-000000000111"), read.stdout); + + const readJson = await ctx.paseo(["chat", "read", "coord-room", "--limit", "10", "--json"]); + assert.strictEqual(readJson.exitCode, 0, readJson.stderr); + const readPayload = JSON.parse(readJson.stdout); + assert.strictEqual(readPayload[0]?.author, "00000000-0000-4000-8000-000000000111"); const waitPromise = ctx.paseo(["chat", "wait", "coord-room", "--timeout", "5s"]); await new Promise((resolve) => setTimeout(resolve, 250)); diff --git a/packages/cli/tests/helpers/test-daemon.ts b/packages/cli/tests/helpers/test-daemon.ts index 43650f6f9..eebb941b6 100644 --- a/packages/cli/tests/helpers/test-daemon.ts +++ b/packages/cli/tests/helpers/test-daemon.ts @@ -333,6 +333,7 @@ export async function runPaseoCli( options?: { timeout?: number; cwd?: string; + env?: NodeJS.ProcessEnv; }, ): Promise<{ exitCode: number; stdout: string; stderr: string }> { const timeout = options?.timeout ?? 60000; @@ -348,6 +349,7 @@ export async function runPaseoCli( ...TEST_DAEMON_ENV_DEFAULTS, PASEO_HOST: `${TEST_DAEMON_HOST}:${ctx.port}`, PASEO_HOME: ctx.paseoHome, + ...options?.env, }, cwd, stdio: ["ignore", "pipe", "pipe"], @@ -399,7 +401,7 @@ export async function createE2ETestContext(options?: { timeout?: number }): Prom /** Run a paseo CLI command against this daemon */ paseo: ( args: string[], - opts?: { timeout?: number; cwd?: string }, + opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv }, ) => Promise<{ exitCode: number; stdout: string; @@ -409,7 +411,7 @@ export async function createE2ETestContext(options?: { timeout?: number }): Prom > { const ctx = await startTestDaemon({ timeout: options?.timeout }); - const paseo = (args: string[], opts?: { timeout?: number; cwd?: string }) => + const paseo = (args: string[], opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv }) => runPaseoCli(ctx, args, opts); return { diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 335c7636e..a9c12c341 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -369,6 +369,7 @@ export type DeleteChatRoomOptions = { export type PostChatMessageOptions = { room: string; body: string; + authorAgentId?: string; replyToMessageId?: string | null; requestId?: string; }; @@ -2911,6 +2912,7 @@ export class DaemonClient { type: "chat/post", room: options.room, body: options.body, + ...(options.authorAgentId ? { authorAgentId: options.authorAgentId } : {}), ...(options.replyToMessageId ? { replyToMessageId: options.replyToMessageId } : {}), }, responseType: "chat/post/response", diff --git a/packages/server/src/server/chat/chat-rpc-schemas.ts b/packages/server/src/server/chat/chat-rpc-schemas.ts index 6dd91fdf2..22a42f84f 100644 --- a/packages/server/src/server/chat/chat-rpc-schemas.ts +++ b/packages/server/src/server/chat/chat-rpc-schemas.ts @@ -30,6 +30,7 @@ export const ChatPostRequestSchema = z.object({ requestId: z.string(), room: z.string(), body: z.string(), + authorAgentId: z.string().optional(), replyToMessageId: z.string().optional(), }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index d897ff8d8..63da9ac78 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -7726,9 +7726,10 @@ export class Session { request: Extract, ): Promise { try { + const authorAgentId = request.authorAgentId?.trim() || this.clientId; const message = await this.chatService.postMessage({ room: request.room, - authorAgentId: this.clientId, + authorAgentId, body: request.body, replyToMessageId: request.replyToMessageId, }); @@ -7742,7 +7743,7 @@ export class Session { }); void notifyChatMentions({ room: request.room, - authorAgentId: this.clientId, + authorAgentId, body: request.body, mentionAgentIds: message.mentionAgentIds, logger: this.sessionLogger, diff --git a/packages/website/posts/drafts/draft-example.md b/packages/website/posts/drafts/draft-example.md new file mode 100644 index 000000000..d1f024bc0 --- /dev/null +++ b/packages/website/posts/drafts/draft-example.md @@ -0,0 +1,7 @@ +--- +title: "Draft Example" +description: "A draft post for testing the draft system." +date: "2026-03-29" +--- + +This is a draft post. It only appears when `?drafts` is in the URL. diff --git a/packages/website/posts/hello-world.md b/packages/website/posts/hello-world.md new file mode 100644 index 000000000..fec30265f --- /dev/null +++ b/packages/website/posts/hello-world.md @@ -0,0 +1,7 @@ +--- +title: "Hello World" +description: "The first post on the Paseo blog." +date: "2026-03-29" +--- + +Welcome to the Paseo blog. diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx index 9de68bc37..636b2f02f 100644 --- a/packages/website/src/components/landing-page.tsx +++ b/packages/website/src/components/landing-page.tsx @@ -16,6 +16,7 @@ import { import { Mic } from "lucide-react"; import { HeroMockup } from "~/components/hero-mockup"; import { ClaudeIcon } from "~/components/mockup"; +import { SiteHeader } from "~/components/site-header"; import "~/styles.css"; interface LandingPageProps { @@ -70,6 +71,12 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {

Product

+ + Blog + - - + ); } diff --git a/packages/website/src/components/site-header.tsx b/packages/website/src/components/site-header.tsx new file mode 100644 index 000000000..a3e2e1ce9 --- /dev/null +++ b/packages/website/src/components/site-header.tsx @@ -0,0 +1,73 @@ +import "~/styles.css"; + +export function SiteHeader() { + return ( +
+ + Paseo + Paseo + + +
+ ); +} diff --git a/packages/website/src/posts.ts b/packages/website/src/posts.ts new file mode 100644 index 000000000..dad83548a --- /dev/null +++ b/packages/website/src/posts.ts @@ -0,0 +1,82 @@ +interface PostFrontmatter { + title: string; + description: string; + date: string; + draft: boolean; +} + +export interface Post { + slug: string; + frontmatter: PostFrontmatter; + content: string; +} + +function parseFrontmatter(raw: string): { data: Record; content: string } { + const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + if (!match) return { data: {}, content: raw }; + + const data: Record = {}; + for (const line of match[1].split("\n")) { + const colonIdx = line.indexOf(":"); + if (colonIdx === -1) continue; + const key = line.slice(0, colonIdx).trim(); + const value = line + .slice(colonIdx + 1) + .trim() + .replace(/^["']|["']$/g, ""); + data[key] = value; + } + + return { data, content: match[2] }; +} + +const postModules = import.meta.glob("../posts/**/*.md", { + eager: true, + query: "?raw", + import: "default", +}) as Record; + +function loadPosts(includeDrafts: boolean): Post[] { + const posts: Post[] = []; + + for (const [path, raw] of Object.entries(postModules)) { + const isDraft = path.includes("/drafts/"); + if (isDraft && !includeDrafts) continue; + + const { data, content } = parseFrontmatter(raw); + const fileName = path.split("/").pop()!.replace(".md", ""); + const slug = isDraft ? `drafts/${fileName}` : fileName; + + posts.push({ + slug, + frontmatter: { + title: data.title ?? "", + description: data.description ?? "", + date: data.date ?? "", + draft: isDraft, + }, + content, + }); + } + + posts.sort( + (a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime(), + ); + + return posts; +} + +export function getPosts(includeDrafts = false): Post[] { + return loadPosts(includeDrafts); +} + +export function getPost(slug: string): Post | undefined { + return loadPosts(true).find((p) => p.slug === slug); +} + +export function formatDate(date: Date): string { + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, "0"); + const day = date.getDate().toString().padStart(2, "0"); + return `${year}-${month}-${day}`; +} diff --git a/packages/website/src/routeTree.gen.ts b/packages/website/src/routeTree.gen.ts index d12dcc7eb..571ab6b0f 100644 --- a/packages/website/src/routeTree.gen.ts +++ b/packages/website/src/routeTree.gen.ts @@ -16,8 +16,10 @@ import { Route as DocsRouteImport } from './routes/docs' import { Route as CodexRouteImport } from './routes/codex' import { Route as ClaudeCodeRouteImport } from './routes/claude-code' import { Route as ChangelogRouteImport } from './routes/changelog' +import { Route as BlogRouteImport } from './routes/blog' import { Route as IndexRouteImport } from './routes/index' import { Route as DocsIndexRouteImport } from './routes/docs/index' +import { Route as BlogIndexRouteImport } from './routes/blog/index' import { Route as DocsWorktreesRouteImport } from './routes/docs/worktrees' import { Route as DocsVoiceRouteImport } from './routes/docs/voice' import { Route as DocsUpdatesRouteImport } from './routes/docs/updates' @@ -25,6 +27,7 @@ import { Route as DocsSecurityRouteImport } from './routes/docs/security' import { Route as DocsConfigurationRouteImport } from './routes/docs/configuration' import { Route as DocsCliRouteImport } from './routes/docs/cli' import { Route as DocsBestPracticesRouteImport } from './routes/docs/best-practices' +import { Route as BlogSplatRouteImport } from './routes/blog/$' const PrivacyRoute = PrivacyRouteImport.update({ id: '/privacy', @@ -61,6 +64,11 @@ const ChangelogRoute = ChangelogRouteImport.update({ path: '/changelog', getParentRoute: () => rootRouteImport, } as any) +const BlogRoute = BlogRouteImport.update({ + id: '/blog', + path: '/blog', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -71,6 +79,11 @@ const DocsIndexRoute = DocsIndexRouteImport.update({ path: '/', getParentRoute: () => DocsRoute, } as any) +const BlogIndexRoute = BlogIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => BlogRoute, +} as any) const DocsWorktreesRoute = DocsWorktreesRouteImport.update({ id: '/worktrees', path: '/worktrees', @@ -106,9 +119,15 @@ const DocsBestPracticesRoute = DocsBestPracticesRouteImport.update({ path: '/best-practices', getParentRoute: () => DocsRoute, } as any) +const BlogSplatRoute = BlogSplatRouteImport.update({ + id: '/$', + path: '/$', + getParentRoute: () => BlogRoute, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/blog': typeof BlogRouteWithChildren '/changelog': typeof ChangelogRoute '/claude-code': typeof ClaudeCodeRoute '/codex': typeof CodexRoute @@ -116,6 +135,7 @@ export interface FileRoutesByFullPath { '/download': typeof DownloadRoute '/opencode': typeof OpencodeRoute '/privacy': typeof PrivacyRoute + '/blog/$': typeof BlogSplatRoute '/docs/best-practices': typeof DocsBestPracticesRoute '/docs/cli': typeof DocsCliRoute '/docs/configuration': typeof DocsConfigurationRoute @@ -123,6 +143,7 @@ export interface FileRoutesByFullPath { '/docs/updates': typeof DocsUpdatesRoute '/docs/voice': typeof DocsVoiceRoute '/docs/worktrees': typeof DocsWorktreesRoute + '/blog/': typeof BlogIndexRoute '/docs/': typeof DocsIndexRoute } export interface FileRoutesByTo { @@ -133,6 +154,7 @@ export interface FileRoutesByTo { '/download': typeof DownloadRoute '/opencode': typeof OpencodeRoute '/privacy': typeof PrivacyRoute + '/blog/$': typeof BlogSplatRoute '/docs/best-practices': typeof DocsBestPracticesRoute '/docs/cli': typeof DocsCliRoute '/docs/configuration': typeof DocsConfigurationRoute @@ -140,11 +162,13 @@ export interface FileRoutesByTo { '/docs/updates': typeof DocsUpdatesRoute '/docs/voice': typeof DocsVoiceRoute '/docs/worktrees': typeof DocsWorktreesRoute + '/blog': typeof BlogIndexRoute '/docs': typeof DocsIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/blog': typeof BlogRouteWithChildren '/changelog': typeof ChangelogRoute '/claude-code': typeof ClaudeCodeRoute '/codex': typeof CodexRoute @@ -152,6 +176,7 @@ export interface FileRoutesById { '/download': typeof DownloadRoute '/opencode': typeof OpencodeRoute '/privacy': typeof PrivacyRoute + '/blog/$': typeof BlogSplatRoute '/docs/best-practices': typeof DocsBestPracticesRoute '/docs/cli': typeof DocsCliRoute '/docs/configuration': typeof DocsConfigurationRoute @@ -159,12 +184,14 @@ export interface FileRoutesById { '/docs/updates': typeof DocsUpdatesRoute '/docs/voice': typeof DocsVoiceRoute '/docs/worktrees': typeof DocsWorktreesRoute + '/blog/': typeof BlogIndexRoute '/docs/': typeof DocsIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/blog' | '/changelog' | '/claude-code' | '/codex' @@ -172,6 +199,7 @@ export interface FileRouteTypes { | '/download' | '/opencode' | '/privacy' + | '/blog/$' | '/docs/best-practices' | '/docs/cli' | '/docs/configuration' @@ -179,6 +207,7 @@ export interface FileRouteTypes { | '/docs/updates' | '/docs/voice' | '/docs/worktrees' + | '/blog/' | '/docs/' fileRoutesByTo: FileRoutesByTo to: @@ -189,6 +218,7 @@ export interface FileRouteTypes { | '/download' | '/opencode' | '/privacy' + | '/blog/$' | '/docs/best-practices' | '/docs/cli' | '/docs/configuration' @@ -196,10 +226,12 @@ export interface FileRouteTypes { | '/docs/updates' | '/docs/voice' | '/docs/worktrees' + | '/blog' | '/docs' id: | '__root__' | '/' + | '/blog' | '/changelog' | '/claude-code' | '/codex' @@ -207,6 +239,7 @@ export interface FileRouteTypes { | '/download' | '/opencode' | '/privacy' + | '/blog/$' | '/docs/best-practices' | '/docs/cli' | '/docs/configuration' @@ -214,11 +247,13 @@ export interface FileRouteTypes { | '/docs/updates' | '/docs/voice' | '/docs/worktrees' + | '/blog/' | '/docs/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + BlogRoute: typeof BlogRouteWithChildren ChangelogRoute: typeof ChangelogRoute ClaudeCodeRoute: typeof ClaudeCodeRoute CodexRoute: typeof CodexRoute @@ -279,6 +314,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChangelogRouteImport parentRoute: typeof rootRouteImport } + '/blog': { + id: '/blog' + path: '/blog' + fullPath: '/blog' + preLoaderRoute: typeof BlogRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -293,6 +335,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DocsIndexRouteImport parentRoute: typeof DocsRoute } + '/blog/': { + id: '/blog/' + path: '/' + fullPath: '/blog/' + preLoaderRoute: typeof BlogIndexRouteImport + parentRoute: typeof BlogRoute + } '/docs/worktrees': { id: '/docs/worktrees' path: '/worktrees' @@ -342,9 +391,28 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DocsBestPracticesRouteImport parentRoute: typeof DocsRoute } + '/blog/$': { + id: '/blog/$' + path: '/$' + fullPath: '/blog/$' + preLoaderRoute: typeof BlogSplatRouteImport + parentRoute: typeof BlogRoute + } } } +interface BlogRouteChildren { + BlogSplatRoute: typeof BlogSplatRoute + BlogIndexRoute: typeof BlogIndexRoute +} + +const BlogRouteChildren: BlogRouteChildren = { + BlogSplatRoute: BlogSplatRoute, + BlogIndexRoute: BlogIndexRoute, +} + +const BlogRouteWithChildren = BlogRoute._addFileChildren(BlogRouteChildren) + interface DocsRouteChildren { DocsBestPracticesRoute: typeof DocsBestPracticesRoute DocsCliRoute: typeof DocsCliRoute @@ -371,6 +439,7 @@ const DocsRouteWithChildren = DocsRoute._addFileChildren(DocsRouteChildren) const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + BlogRoute: BlogRouteWithChildren, ChangelogRoute: ChangelogRoute, ClaudeCodeRoute: ClaudeCodeRoute, CodexRoute: CodexRoute, diff --git a/packages/website/src/routes/blog.tsx b/packages/website/src/routes/blog.tsx new file mode 100644 index 000000000..f7330969b --- /dev/null +++ b/packages/website/src/routes/blog.tsx @@ -0,0 +1,19 @@ +import { createFileRoute, Outlet } from "@tanstack/react-router"; +import { SiteHeader } from "~/components/site-header"; + +export const Route = createFileRoute("/blog")({ + component: BlogLayout, +}); + +function BlogLayout() { + return ( +
+
+
+ +
+ +
+
+ ); +} diff --git a/packages/website/src/routes/blog/$.tsx b/packages/website/src/routes/blog/$.tsx new file mode 100644 index 000000000..6c32fb3d6 --- /dev/null +++ b/packages/website/src/routes/blog/$.tsx @@ -0,0 +1,40 @@ +import { createFileRoute } from "@tanstack/react-router"; +import ReactMarkdown from "react-markdown"; +import { getPost, formatDate } from "~/posts"; +import { pageMeta } from "~/meta"; + +export const Route = createFileRoute("/blog/$")({ + head: ({ params }) => { + const slug = params._splat ?? ""; + const post = getPost(slug); + if (!post) return { meta: pageMeta("Not Found - Paseo", "Post not found.") }; + return { + meta: pageMeta(`${post.frontmatter.title} - Paseo`, post.frontmatter.description), + }; + }, + component: BlogPost, +}); + +function BlogPost() { + const { _splat } = Route.useParams(); + const slug = _splat ?? ""; + const post = getPost(slug); + + if (!post) { + return

Post not found.

; + } + + return ( +
+
+

{post.frontmatter.title}

+ + {formatDate(new Date(post.frontmatter.date))} + +
+
+ {post.content} +
+
+ ); +} diff --git a/packages/website/src/routes/blog/index.tsx b/packages/website/src/routes/blog/index.tsx new file mode 100644 index 000000000..bb2ccb279 --- /dev/null +++ b/packages/website/src/routes/blog/index.tsx @@ -0,0 +1,53 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { getPosts, formatDate } from "~/posts"; +import { pageMeta } from "~/meta"; + +export const Route = createFileRoute("/blog/")({ + validateSearch: (search: Record) => ({ + drafts: search.drafts !== undefined, + }), + head: () => ({ + meta: pageMeta("Blog - Paseo", "Updates, thoughts, and announcements from the Paseo team."), + }), + component: BlogIndex, +}); + +function BlogIndex() { + const { drafts } = Route.useSearch(); + const posts = getPosts(drafts); + + return ( +
+ {drafts && ( +
+

Showing draft posts

+
+ )} +
+ {posts.map(({ slug, frontmatter }) => ( +
+ + {formatDate(new Date(frontmatter.date))} + + + {frontmatter.title} + {frontmatter.draft && ( + + DRAFT + + )} + +
+ ))} + {posts.length === 0 &&

No posts yet.

} +
+
+ ); +} diff --git a/packages/website/src/routes/changelog.tsx b/packages/website/src/routes/changelog.tsx index f38354aa1..89a0ca986 100644 --- a/packages/website/src/routes/changelog.tsx +++ b/packages/website/src/routes/changelog.tsx @@ -1,8 +1,8 @@ -import { Link, createFileRoute } from "@tanstack/react-router"; +import { createFileRoute } from "@tanstack/react-router"; import ReactMarkdown from "react-markdown"; import changelogMarkdown from "../../../../CHANGELOG.md?raw"; import { pageMeta } from "~/meta"; -import "~/styles.css"; +import { SiteHeader } from "~/components/site-header"; export const Route = createFileRoute("/changelog")({ head: () => ({ @@ -18,56 +18,9 @@ function Changelog() { return (
-
- - Paseo - Paseo - -
- - Docs - - - - - - - - - - - -
-
- +
+ +
{changelogMarkdown}
diff --git a/packages/website/src/styles.css b/packages/website/src/styles.css index b518feaa2..cfc36d171 100644 --- a/packages/website/src/styles.css +++ b/packages/website/src/styles.css @@ -80,6 +80,112 @@ color: rgba(250, 250, 250, 0.8); } +/* Blog post prose styles — adapted from moboudra.com */ +.blog-prose { + color: rgba(250, 250, 250, 0.82); + line-height: 1.8; + font-size: 1.125rem; +} + +.blog-prose h1, +.blog-prose h2, +.blog-prose h3 { + color: var(--color-foreground); + font-weight: 600; + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--color-primary) 50%, transparent); + text-decoration-thickness: 2px; + text-underline-offset: 4px; +} + +.blog-prose h1, +.blog-prose h2 { + font-size: 1.5rem; + line-height: 1.3; + margin-top: 2.5rem; + margin-bottom: 0.75rem; +} + +.blog-prose h3 { + font-size: 1.25rem; + line-height: 1.4; + margin-top: 2rem; + margin-bottom: 0.5rem; +} + +.blog-prose p { + margin-top: 1rem; + margin-bottom: 1rem; +} + +.blog-prose a { + color: var(--color-foreground); + text-decoration: underline; + text-underline-offset: 2px; +} + +.blog-prose a:hover { + color: var(--color-primary); +} + +.blog-prose ul, +.blog-prose ol { + margin-top: 1rem; + margin-bottom: 1rem; + padding-left: 1.5rem; +} + +.blog-prose ul { + list-style: disc; +} + +.blog-prose ol { + list-style: decimal; +} + +.blog-prose li { + margin-top: 0.25rem; + margin-bottom: 0.25rem; +} + +.blog-prose blockquote { + border-left: 3px solid var(--color-border); + padding-left: 1rem; + color: rgba(250, 250, 250, 0.6); + margin-top: 1rem; + margin-bottom: 1rem; +} + +.blog-prose pre { + background-color: var(--color-card); + border: 1px solid var(--color-border); + border-radius: 0.5rem; + padding: 1rem; + overflow-x: auto; + margin-top: 1rem; + margin-bottom: 1rem; +} + +.blog-prose :not(pre) > code { + background-color: var(--color-muted); + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + font-size: 0.875em; +} + +.blog-prose img { + max-width: 100%; + border-radius: 0.5rem; + margin-top: 1rem; + margin-bottom: 1rem; +} + +.blog-prose hr { + border-color: var(--color-border); + margin-top: 2rem; + margin-bottom: 2rem; +} + @theme { --color-background: #0a0a0a; --color-foreground: #fafafa; diff --git a/packages/website/src/types/vite-env.d.ts b/packages/website/src/types/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/packages/website/src/types/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/website/vite.config.ts b/packages/website/vite.config.ts index 4d32c70ef..d1506d0f8 100644 --- a/packages/website/vite.config.ts +++ b/packages/website/vite.config.ts @@ -7,6 +7,26 @@ import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; const repoRoot = path.resolve(__dirname, "../.."); +const siteHost = "https://paseo.sh"; +const sitemapPages = [ + "/", + "/changelog", + "/claude-code", + "/codex", + "/docs", + "/download", + "/opencode", + "/privacy", + "/docs/best-practices", + "/docs/cli", + "/docs/configuration", + "/docs/security", + "/docs/updates", + "/docs/voice", + "/docs/worktrees", +].map((routePath) => ({ + path: routePath, +})); export default defineConfig({ server: { @@ -18,7 +38,12 @@ export default defineConfig({ plugins: [ cloudflare({ viteEnvironment: { name: "ssr" } }), tsConfigPaths(), - tanstackStart(), + tanstackStart({ + pages: sitemapPages, + sitemap: { + host: siteHost, + }, + }), react(), tailwindcss(), ],