llm browser

This commit is contained in:
developer committed 2026-10-19 15:25:27 +00:00
1 parent afed023152
commit a4dbb75195
39 files changed
+3709 -58

No files matched your search

+297
View File
@@ -2226,3 +2226,300 @@ footer {
@media (max-width: 760px) {
.overview-grid { padding-bottom: var(--space-9); }
}
/* ── Agent view ─────────────────────────────────────────────────────────
The LLM browser: domain in, agent-eye view out. All colors come from
tokens; derived shades use color-mix so both themes stay correct. */
.agent-url-row {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.agent-url-row input {
flex: 1;
min-width: min(100%, 22rem);
}
.agent-examples {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
margin-top: var(--space-3);
}
.agent-example {
padding: var(--space-1) var(--space-3);
font-size: var(--text-xs);
font-family: var(--font-mono);
}
.agent-head .agent-url {
font-size: var(--text-sm);
color: var(--foreground);
word-break: break-all;
}
.agent-note {
font-size: var(--text-xs);
padding: 0 var(--space-2);
}
.agent-doc {
padding: var(--space-5);
overflow-x: auto;
}
/* The converted page renders inside the shared .markdown-body editorial
styles; relax its centered-article metrics for the app surface and fill
in the shapes the demo never needed (links, lists, tables, code). */
.agent-doc .markdown-body {
max-width: none;
margin: 0;
padding: 0;
}
.agent-doc .markdown-body h1 {
font-size: var(--text-2xl);
letter-spacing: -0.04em;
line-height: 1.15;
margin: var(--space-5) 0 var(--space-3);
}
.agent-doc .markdown-body h2 {
font-size: var(--text-xl);
letter-spacing: -0.03em;
line-height: 1.2;
margin: var(--space-5) 0 var(--space-2);
}
.agent-doc .markdown-body h5,
.agent-doc .markdown-body h6 {
font-size: var(--text-md);
margin: var(--space-4) 0 var(--space-1);
}
.agent-doc .markdown-body a {
color: var(--accent);
text-decoration: underline;
text-decoration-color: color-mix(in srgb, var(--accent) 45%, transparent);
text-underline-offset: 2px;
}
.agent-doc .markdown-body a:hover {
text-decoration-color: var(--accent);
}
.agent-doc .markdown-body ul,
.agent-doc .markdown-body ol {
margin: 0 0 var(--space-4);
padding-left: 1.4rem;
color: var(--muted-foreground);
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.agent-doc .markdown-body blockquote {
margin: 0 0 var(--space-4);
padding: var(--space-2) var(--space-4);
border-left: 2px solid color-mix(in srgb, var(--accent) 55%, var(--border));
color: var(--muted-foreground);
font-style: italic;
}
.agent-doc .markdown-body blockquote p {
margin: 0;
}
.agent-doc .markdown-body pre {
margin: 0 0 var(--space-4);
padding: var(--space-3) var(--space-4);
background: var(--muted);
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow-x: auto;
}
.agent-doc .markdown-body pre code {
background: none;
border: none;
padding: 0;
color: var(--foreground);
font-size: var(--text-xs);
line-height: 1.6;
}
.agent-doc .markdown-body table {
width: 100%;
border-collapse: collapse;
margin: 0 0 var(--space-4);
font-size: var(--text-sm);
}
.agent-doc .markdown-body th,
.agent-doc .markdown-body td {
border: 1px solid var(--border);
padding: var(--space-2) var(--space-3);
text-align: left;
}
.agent-doc .markdown-body th {
background: color-mix(in srgb, var(--accent) 7%, var(--card));
}
.agent-doc .markdown-body hr {
border: none;
border-top: 1px solid var(--border);
margin: var(--space-5) 0;
}
.agent-doc .markdown-body img {
max-width: 100%;
border-radius: var(--radius-sm);
}
/* Raw source + companion files */
.agent-raw {
margin: 0;
padding: var(--space-4);
background: var(--muted);
color: var(--foreground);
font-size: var(--text-xs);
line-height: 1.6;
overflow: auto;
max-height: 26rem;
white-space: pre-wrap;
word-break: break-word;
}
.agent-file-url {
font-size: var(--text-2xs);
text-decoration: none;
word-break: break-all;
}
.agent-file-url:hover {
color: var(--foreground);
}
/* Parsed llms.txt */
.agent-llms {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.agent-llms-title {
margin: 0;
font-size: var(--text-lg);
letter-spacing: -0.02em;
}
.agent-llms-quote {
margin: 0;
padding: var(--space-2) var(--space-4);
border-left: 2px solid color-mix(in srgb, var(--accent) 55%, var(--border));
color: var(--muted-foreground);
font-style: italic;
font-size: var(--text-sm);
}
.agent-llms-section h4 {
margin: var(--space-2) 0 0;
font-size: var(--text-md);
}
.agent-llms-section > p {
margin: 0;
font-size: var(--text-xs);
}
.agent-llms-section ul {
list-style: disc;
margin: var(--space-2) 0 0;
padding-left: 1.3rem;
display: flex;
flex-direction: column;
gap: var(--space-1);
font-size: var(--text-sm);
}
.agent-llms-section a {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 2px;
}
/* Meta table */
.agent-meta {
margin: 0;
padding: var(--space-2) var(--space-5) var(--space-4);
}
.agent-meta-row {
display: grid;
grid-template-columns: minmax(110px, max-content) 1fr;
gap: var(--space-4);
padding: var(--space-2) 0;
border-bottom: 1px solid var(--border);
font-size: var(--text-sm);
}
.agent-meta-row:last-child {
border-bottom: none;
}
.agent-meta-row dd {
margin: 0;
word-break: break-word;
}
/* Checks */
.agent-checks-summary {
padding: var(--space-4) var(--space-5) 0;
}
.agent-checks {
list-style: none;
margin: 0;
padding: var(--space-2) var(--space-5) var(--space-4);
}
.agent-check {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-3) 0;
border-bottom: 1px solid var(--border);
}
.agent-check:last-child {
border-bottom: none;
}
.agent-check .badge {
flex-shrink: 0;
min-width: 3.5rem;
justify-content: center;
text-transform: uppercase;
}
.agent-check-label {
font-weight: 600;
font-size: var(--text-sm);
}
.agent-check-detail {
font-size: var(--text-xs);
line-height: 1.55;
}
@media (max-width: 720px) {
.agent-meta-row {
grid-template-columns: 1fr;
gap: var(--space-1);
}
}
+82
View File
@@ -0,0 +1,82 @@
/* Typed client for the browse resource — the agent view. The wire format
is owned by backend/internal/browse (model.go); these types mirror it. */
import { apiFetch } from './client'
export type Severity = 'ok' | 'info' | 'warn' | 'error'
export type Check = {
id: string
label: string
severity: Severity
detail: string
}
export type PageView = {
title: string
description: string
canonical: string
robotsMeta: string
lang: string
h1: string
linkCount: number
deadLinks: number
scriptCount: number
imageCount: number
wordCount: number
textLength: number
}
export type LlmsLink = {
title: string
url: string
description: string
}
export type LlmsSection = {
heading: string
description: string
links: LlmsLink[]
}
export type LlmsDoc = {
title: string
description: string
sections: LlmsSection[]
linkCount: number
}
export type FileResult = {
url: string
status: number
contentType: string
error?: string
content: string
truncated: boolean
llmsDoc?: LlmsDoc
}
export type BrowseFiles = {
llmsTxt: FileResult | null
llmsFullTxt: FileResult | null
wellKnownLlmsTxt: FileResult | null
robotsTxt: FileResult | null
}
export type Snapshot = {
requestedUrl: string
finalUrl: string
status: number
contentType: string
durationMs: number
fetchedAt: string
page?: PageView
markdown: string
markdownTruncated: boolean
files: BrowseFiles
checks: Check[]
}
/** GET /api/v1/browse?url=… — a bare domain or a full URL both work. */
export function browse(url: string): Promise<Snapshot> {
return apiFetch<Snapshot>(`/api/v1/browse?url=${encodeURIComponent(url)}`)
}
+26 -10
View File
@@ -11,6 +11,7 @@ import { PrimitivesPage } from '../pages/PrimitivesPage'
import { MarkdownPage } from '../pages/MarkdownPage'
import { EditorPage } from '../pages/EditorPage'
import { ItemsPage } from '../pages/ItemsPage'
import { AgentViewPage } from '../pages/AgentViewPage'
/* ── The route table ─────────────────────────────────────────────────────────
One registry drives the router, the sidebar, the breadcrumbs and the ⌘K
@@ -23,6 +24,9 @@ export const PATTERNS_PATH = '/patterns'
/** Live backend demo page (vite proxies /api to the Go server on :8080). */
export const ITEMS_PATH = '/items'
/** The agent view: enter a domain, see what an LLM sees. */
export const AGENT_VIEW_PATH = '/agent-view'
export const ROUTE_GROUPS = ['Application Patterns', 'Components', 'Foundations', 'Documentation'] as const
export type RouteGroup = (typeof ROUTE_GROUPS)[number]
@@ -50,11 +54,23 @@ export type AppRoute = {
}
export const routes: AppRoute[] = [
{
path: AGENT_VIEW_PATH,
group: 'Application Patterns',
label: 'Agent view',
index: '01',
kicker: 'AGENT TOOLING',
title: 'Agent view — browse as an LLM',
description:
'Enter a domain and see what an agent sees: the page as markdown with followable links, its llms.txt map, robots rules and readiness checks.',
meta: <span className="api-pill">/api/v1/browse · llms.txt</span>,
page: AgentViewPage,
},
{
path: PATTERNS_PATH,
group: 'Application Patterns',
label: 'Application Patterns',
index: '01',
index: '02',
kicker: 'PATTERNS & EXAMPLES',
title: 'Reusable Application Patterns',
description:
@@ -66,7 +82,7 @@ export const routes: AppRoute[] = [
path: '/components/buttons',
group: 'Components',
label: 'Buttons',
index: '02',
index: '03',
kicker: 'COMPONENTS',
title: 'Buttons',
description: 'Actions with a clear hierarchy and dependable feedback.',
@@ -77,7 +93,7 @@ export const routes: AppRoute[] = [
path: '/components/icons',
group: 'Components',
label: 'Icons',
index: '03',
index: '04',
kicker: 'COMPONENTS',
title: 'Icons (Lucide)',
description:
@@ -89,7 +105,7 @@ export const routes: AppRoute[] = [
path: '/foundations/colors',
group: 'Foundations',
label: 'Color tokens',
index: '04',
index: '05',
kicker: 'FOUNDATIONS',
title: 'Color tokens',
description: 'Functional color roles in CSS Color 4 lab() keep every surface intentional.',
@@ -100,7 +116,7 @@ export const routes: AppRoute[] = [
path: '/foundations/typography',
group: 'Foundations',
label: 'Typography',
index: '05',
index: '06',
kicker: 'FOUNDATIONS',
title: 'Typography',
description: 'Modular rem-based type scale with dual-family hierarchy (Manrope & DM Mono).',
@@ -111,7 +127,7 @@ export const routes: AppRoute[] = [
path: '/components/forms',
group: 'Components',
label: 'Form controls',
index: '06',
index: '07',
kicker: 'PRIMITIVES',
title: 'Form controls',
description: 'Inputs designed for focus, flow, and the occasional messy edge case.',
@@ -121,7 +137,7 @@ export const routes: AppRoute[] = [
path: '/components/code',
group: 'Components',
label: 'Composable code',
index: '07',
index: '08',
kicker: 'CODE',
title: 'Composable by default',
description: 'Small APIs. Strong defaults. No surprises.',
@@ -131,7 +147,7 @@ export const routes: AppRoute[] = [
path: '/docs/markdown',
group: 'Documentation',
label: 'Markdown rendering',
index: '08',
index: '09',
kicker: 'CONTENT',
title: 'Markdown rendering',
description: 'A readable editorial layer for changelogs, docs, and product notes.',
@@ -142,7 +158,7 @@ export const routes: AppRoute[] = [
path: '/docs/editor',
group: 'Documentation',
label: 'CodeMirror theme',
index: '09',
index: '10',
kicker: 'DEVELOPER TOOLING',
title: 'CodeMirror theme',
description: 'A focused editor palette for examples, playgrounds, and embedded code.',
@@ -153,7 +169,7 @@ export const routes: AppRoute[] = [
path: ITEMS_PATH,
group: 'Application Patterns',
label: 'Items (live API)',
index: '10',
index: '11',
kicker: 'FULLSTACK',
title: 'Live data — Go API + SWR',
description:
@@ -18,6 +18,14 @@ export type SearchItem = {
const SEARCH_INDEX: SearchItem[] = [
// Patterns
{
id: 'pat-agent-view',
title: 'Agent view — browse as an LLM',
description: 'Enter a domain, see the page as markdown, its llms.txt map and readiness checks',
category: 'Patterns',
icon: 'cpu',
path: '/agent-view',
},
{
id: 'pat-metrics',
title: 'Metrics Dashboard',
@@ -0,0 +1,139 @@
/* React renderer for the agent view's markdown. Builds elements directly
from parse.ts output — no HTML injection. Every link click is routed
through onNavigate so "browsing" stays inside the agent view: the viewer
fetches the linked page and renders it the same way, which is exactly
the loop an agent is in. */
import { useMemo, type AnchorHTMLAttributes, type ReactNode } from 'react'
import { parseMarkdown, type InlineSpan, type ListBlock, type MarkdownBlock } from './parse'
export function MarkdownView({
source,
onNavigate,
className,
}: {
source: string
/** Called with the absolute href when the visitor opens a link. */
onNavigate?: (href: string) => void
className?: string
}) {
const blocks = useMemo(() => parseMarkdown(source), [source])
return (
<div className={className}>
{blocks.map((block, index) => (
<BlockView key={index} block={block} onNavigate={onNavigate} />
))}
</div>
)
}
function BlockView({ block, onNavigate }: { block: MarkdownBlock; onNavigate?: (href: string) => void }) {
switch (block.kind) {
case 'heading': {
const Tag = `h${block.level}` as 'h1'
return <Tag>{block.spans.map((span, i) => <SpanView key={i} span={span} onNavigate={onNavigate} />)}</Tag>
}
case 'paragraph':
return <p>{block.spans.map((span, i) => <SpanView key={i} span={span} onNavigate={onNavigate} />)}</p>
case 'code':
return (
<pre>
<code>{block.text}</code>
</pre>
)
case 'quote':
return (
<blockquote>
{block.blocks.map((child, i) => (
<BlockView key={i} block={child} onNavigate={onNavigate} />
))}
</blockquote>
)
case 'list':
return <ListView list={block} onNavigate={onNavigate} />
case 'table':
return (
<table>
<thead>
<tr>
{block.head.map((cell, i) => (
<th key={i}>{cell}</th>
))}
</tr>
</thead>
<tbody>
{block.rows.map((row, r) => (
<tr key={r}>
{row.map((cell, c) => (
<td key={c}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
)
case 'hr':
return <hr />
}
}
function ListView({ list, onNavigate }: { list: ListBlock; onNavigate?: (href: string) => void }) {
const Tag = list.ordered ? 'ol' : 'ul'
return (
<Tag start={list.ordered && list.start !== 1 ? list.start : undefined}>
{list.items.map((item, index) => (
<li key={index}>
{item.spans.map((span, i) => (
<SpanView key={i} span={span} onNavigate={onNavigate} />
))}
{item.sub && <ListView list={item.sub} onNavigate={onNavigate} />}
</li>
))}
</Tag>
)
}
function SpanView({ span, onNavigate }: { span: InlineSpan; onNavigate?: (href: string) => void }) {
switch (span.kind) {
case 'text':
return <>{span.text}</>
case 'code':
return <code>{span.text}</code>
case 'strong':
return <strong>{span.spans.map((child, i) => <SpanView key={i} span={child} onNavigate={onNavigate} />)}</strong>
case 'em':
return <em>{span.spans.map((child, i) => <SpanView key={i} span={child} onNavigate={onNavigate} />)}</em>
case 'del':
return <del>{span.spans.map((child, i) => <SpanView key={i} span={child} onNavigate={onNavigate} />)}</del>
case 'image':
return <img src={span.src} alt={span.alt} loading="lazy" />
case 'link':
return <AgentLink href={span.href} onNavigate={onNavigate}>{renderInline(span.spans, onNavigate)}</AgentLink>
}
}
function AgentLink({
href,
onNavigate,
children,
...props
}: AnchorHTMLAttributes<HTMLAnchorElement> & { href: string; onNavigate?: (href: string) => void }) {
const followable = /^https?:\/\//i.test(href)
return (
<a
href={href}
onClick={event => {
if (!followable || !onNavigate) return
event.preventDefault() // the agent view never leaves itself
onNavigate(href)
}}
title={followable ? 'Open in the agent view' : undefined}
{...props}
>
{children}
</a>
)
}
function renderInline(spans: InlineSpan[], onNavigate?: (href: string) => void): ReactNode {
return spans.map((span, i) => <SpanView key={i} span={span} onNavigate={onNavigate} />)
}
+255
View File
@@ -0,0 +1,255 @@
/* A small markdown block/inline parser for the agent view. The backend
converts HTML to well-behaved markdown (headings, paragraphs, lists,
fenced code, blockquotes, GFM tables, links, images) — exactly the shapes
this parser handles, so the renderer can build React elements directly
without dangerouslySetInnerHTML. Unknown syntax degrades to paragraphs. */
export type InlineSpan =
| { kind: 'text'; text: string }
| { kind: 'code'; text: string }
| { kind: 'strong'; spans: InlineSpan[] }
| { kind: 'em'; spans: InlineSpan[] }
| { kind: 'del'; spans: InlineSpan[] }
| { kind: 'link'; href: string; spans: InlineSpan[] }
| { kind: 'image'; alt: string; src: string }
export type ListItem = {
spans: InlineSpan[]
/** A list nested under this item. */
sub?: ListBlock
}
export type ListBlock = {
kind: 'list'
ordered: boolean
start: number
items: ListItem[]
}
export type MarkdownBlock =
| { kind: 'heading'; level: number; spans: InlineSpan[] }
| { kind: 'paragraph'; spans: InlineSpan[] }
| { kind: 'code'; language: string; text: string }
| { kind: 'quote'; blocks: MarkdownBlock[] }
| ListBlock
| { kind: 'table'; head: string[]; rows: string[][] }
| { kind: 'hr' }
const headingRe = /^(#{1,6})\s+(.*)$/
const hrRe = /^ {0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/
const fenceRe = /^ {0,3}(`{3,}|~{3,})\s*([^\s`]*)\s*$/
const quoteRe = /^ {0,3}>\s?(.*)$/
const itemRe = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/
const tableRowRe = /^\s*.*\|.*$/
const tableSepRe = /^\s*\|?\s*:?-{2,}[\s:|-]*\|?\s*$/
/** Parse a markdown document into renderable blocks. */
export function parseMarkdown(source: string): MarkdownBlock[] {
return parseBlocks(source.split('\n')).blocks
}
function parseBlocks(lines: string[]): { blocks: MarkdownBlock[]; next: number } {
const blocks: MarkdownBlock[] = []
let i = 0
outer: while (i < lines.length) {
const line = lines[i]
if (line === undefined) break
if (line.trim() === '') {
i++
continue
}
const fence = fenceRe.exec(line)
if (fence) {
const marker = fence[1] ?? '`'
const language = fence[2] ?? ''
const body: string[] = []
i++
while (i < lines.length) {
const current = lines[i]
if (current === undefined) break
if (new RegExp(`^ {0,3}${marker[0]}{${marker.length},}\\s*$`).test(current)) break
body.push(current)
i++
}
i++ // closing fence (or EOF)
blocks.push({ kind: 'code', language, text: body.join('\n') })
continue
}
const heading = headingRe.exec(line)
if (heading) {
blocks.push({
kind: 'heading',
level: heading[1]?.length ?? 1,
spans: parseInline(heading[2] ?? ''),
})
i++
continue
}
if (hrRe.test(line)) {
blocks.push({ kind: 'hr' })
i++
continue
}
const quote = quoteRe.exec(line)
if (quote) {
const inner: string[] = [quote[1] ?? '']
i++
while (i < lines.length) {
const cont = quoteRe.exec(lines[i] ?? '')
if (!cont) break
inner.push(cont[1] ?? '')
i++
}
blocks.push({ kind: 'quote', blocks: parseBlocks(inner).blocks })
continue
}
const item = itemRe.exec(line)
if (item) {
const parsed = parseList(lines, i, item[1]?.length ?? 0)
blocks.push(parsed.list)
i = parsed.next
continue
}
const nextLine = lines[i + 1]
if (tableRowRe.test(line) && nextLine !== undefined && tableSepRe.test(nextLine)) {
const head = splitTableRow(line)
i += 2
const rows: string[][] = []
while (i < lines.length) {
const rowLine = lines[i]
if (rowLine === undefined || !tableRowRe.test(rowLine) || rowLine.trim() === '') break
rows.push(splitTableRow(rowLine))
i++
}
blocks.push({ kind: 'table', head, rows })
continue
}
// paragraph: gather until a blank line or the start of another block
const text: string[] = [line]
i++
while (i < lines.length) {
const next = lines[i]
if (next === undefined) break
if (
next.trim() === '' ||
headingRe.test(next) ||
hrRe.test(next) ||
fenceRe.test(next) ||
quoteRe.test(next) ||
itemRe.test(next)
) {
break
}
text.push(next)
i++
}
blocks.push({ kind: 'paragraph', spans: parseInline(text.join(' ').trim()) })
if (blocks.length > 5000) break outer // pathological input guard
}
return { blocks, next: i }
}
/** Parse one (possibly nested) list starting at `start` with base indent. */
function parseList(
lines: string[],
start: number,
indent: number,
): { list: ListBlock; next: number } {
const first = itemRe.exec(lines[start] ?? '')
const firstMarker = first?.[2] ?? ''
const ordered = /\d/.test(firstMarker)
const startNumber = ordered ? parseInt(firstMarker, 10) || 1 : 1
const items: ListItem[] = []
let i = start
while (i < lines.length) {
const line = lines[i]
if (line === undefined) break
if (line.trim() === '') {
// a blank line only ends the list if the next line is not a sibling item
if (i + 1 < lines.length && siblingItem(lines[i + 1] ?? '', indent)) {
i++
continue
}
break
}
const match = itemRe.exec(line)
if (!match) break
const itemIndent = match[1]?.length ?? 0
if (itemIndent < indent) break
if (itemIndent > indent) {
// deeper indent: nested list under the previous item
if (items.length > 0) {
const nested = parseList(lines, i, itemIndent)
const parent = items[items.length - 1]
if (parent) parent.sub = nested.list
i = nested.next
continue
}
break
}
const marker = match[2] ?? ''
if (ordered !== /\d/.test(marker)) break
items.push({ spans: parseInline(match[3] ?? '') })
i++
}
return { list: { kind: 'list', ordered, start: startNumber, items }, next: i }
}
function siblingItem(line: string, indent: number): boolean {
const match = itemRe.exec(line)
return match !== null && (match[1]?.length ?? 0) === indent
}
function splitTableRow(line: string): string[] {
let s = line.trim()
if (s.startsWith('|')) s = s.slice(1)
if (s.endsWith('|')) s = s.slice(0, -1)
return s.split('|').map(cell => cell.trim())
}
/* ── Inline parsing ──────────────────────────────────────────────────── */
const spanRe =
/(!\[[^\]]*\]\([^)]*\))|(\[[^\]]*\]\([^)]*\))|(`[^`\n]+`)|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(~~[^~\n]+~~)|(\*[^*\n]+\*)|(_[^_\n]+_)/g
export function parseInline(text: string): InlineSpan[] {
const spans: InlineSpan[] = []
let last = 0
for (const match of text.matchAll(spanRe)) {
const index = match.index ?? 0
if (index > last) spans.push({ kind: 'text', text: text.slice(last, index) })
spans.push(...parseSpan(match[0]))
last = index + match[0].length
}
if (last < text.length) spans.push({ kind: 'text', text: text.slice(last) })
return spans
}
function parseSpan(raw: string): InlineSpan[] {
if (raw.startsWith('![')) {
const m = /^\!\[([^\]]*)\]\(([^)\s]*)(?:\s+"[^"]*")?\)$/.exec(raw)
if (m) return [{ kind: 'image', alt: m[1] ?? '', src: m[2] ?? '' }]
}
if (raw.startsWith('[')) {
const m = /^\[([^\]]*)\]\(([^)\s]*)(?:\s+"[^"]*")?\)$/.exec(raw)
if (m) return [{ kind: 'link', href: m[2] ?? '', spans: parseInline(m[1] ?? '') }]
}
if (raw.startsWith('`')) return [{ kind: 'code', text: raw.slice(1, -1) }]
if (raw.startsWith('~~')) return [{ kind: 'del', spans: parseInline(raw.slice(2, -2)) }]
if (raw.startsWith('**') || raw.startsWith('__')) {
return [{ kind: 'strong', spans: parseInline(raw.slice(2, -2)) }]
}
return [{ kind: 'em', spans: parseInline(raw.slice(1, -1)) }]
}
+20
View File
@@ -0,0 +1,20 @@
/* SWR data hook for the agent view. A null url means "idle" — no request
goes out until the visitor submits a domain. Snapshots are keyed by URL
so navigating back to a previously inspected page is instant from cache;
refresh() re-runs the fetch (sites change). */
import useSWR from 'swr'
import * as browseApi from '../api/browse'
import type { Snapshot } from '../api/browse'
export const browseKeys = {
snapshot: (url: string) => ['browse', 'snapshot', url] as const,
}
export function useBrowse(url: string | null) {
const { data, error, isLoading, mutate } = useSWR<Snapshot>(
url !== null ? browseKeys.snapshot(url) : null,
() => browseApi.browse(url as string),
{ keepPreviousData: true },
)
return { snapshot: data, error, isLoading, refresh: mutate }
}
+503
View File
@@ -0,0 +1,503 @@
/* The agent view: enter a domain and see the page the way a text-only
agent sees it — markdown, llms.txt companions, robots.txt and the
readiness checks. The inspected URL lives in ?url= so any page is
linkable, and link clicks re-enter the viewer (the agent loop). */
import { useState, type FormEvent } from 'react'
import { useLocation, useSearch } from 'wouter'
import {
Alert,
AlertDescription,
AlertTitle,
Badge,
Button,
Card,
CardDescription,
CardHeader,
CardTitle,
EmptyState,
EmptyStateActions,
EmptyStateDescription,
EmptyStateIcon,
EmptyStateTitle,
Icon,
Skeleton,
Spinner,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '../components/ui'
import { MarkdownView } from '../components/markdown/MarkdownView'
import { TextInput } from '../components/FormField'
import { ApiError } from '../api/client'
import type { Check, FileResult, LlmsDoc, Severity, Snapshot } from '../api/browse'
import { useBrowse } from '../hooks/useBrowse'
const EXAMPLES = ['anthropic.com', 'example.com', 'developer.mozilla.org']
/** What to send to the API: ?url= is already normalized server-side. */
function urlFromSearch(search: string): string | null {
const value = new URLSearchParams(search).get('url')?.trim()
return value ? value : null
}
export function AgentViewPage() {
const [location, navigate] = useLocation()
const search = useSearch()
const activeUrl = urlFromSearch(search)
const [input, setInput] = useState(activeUrl ?? '')
const [tab, setTab] = useState('markdown')
const { snapshot, error, isLoading, refresh } = useBrowse(activeUrl)
/** Enter the agent loop at another URL (form submit or link click). */
const goTo = (value: string) => {
const target = value.trim()
if (!target) return
setInput(target)
setTab('markdown')
navigate(`${location.split('?')[0]}?url=${encodeURIComponent(target)}`)
}
const handleSubmit = (event: FormEvent) => {
event.preventDefault()
goTo(input)
}
return (
<div className="stack-md">
<Card>
<CardHeader>
<CardTitle>Browse as an agent</CardTitle>
<CardDescription>
Fetches the page server-side without JavaScript, converts it to markdown, and gathers
the <code className="mono">llms.txt</code> files — exactly what an LLM sees when it
“opens” this site.
</CardDescription>
</CardHeader>
<form className="agent-url-row" onSubmit={handleSubmit}>
<TextInput
value={input}
onChange={event => setInput(event.target.value)}
placeholder="example.com or https://example.com/docs"
aria-label="Domain or URL to inspect"
autoFocus
/>
<Button type="submit" variant="primary" disabled={input.trim() === '' || (isLoading && activeUrl !== null)}>
{isLoading && activeUrl !== null ? <Spinner size="xs" /> : <Icon name="search" size={14} />}
View as agent
</Button>
</form>
<div className="agent-examples">
{EXAMPLES.map(example => (
<button key={example} type="button" className="button ghost agent-example" onClick={() => goTo(example)}>
{example}
</button>
))}
</div>
</Card>
{error && !snapshot ? (
<BrowseError error={error} onRetry={() => refresh()} />
) : !activeUrl ? (
<ExplainCard />
) : isLoading && !snapshot ? (
<LoadingCard />
) : snapshot ? (
<SnapshotView snapshot={snapshot} tab={tab} onTab={setTab} onNavigate={goTo} onRefresh={() => refresh()} />
) : null}
</div>
)
}
/* ── States ─────────────────────────────────────────────────────────── */
function ExplainCard() {
return (
<EmptyState>
<EmptyStateIcon>
<Icon name="cpu" size={26} />
</EmptyStateIcon>
<EmptyStateTitle>What does your site look like to an LLM?</EmptyStateTitle>
<EmptyStateDescription>
Agents never run JavaScript and often skip the HTML entirely: they read markdown and{' '}
<code className="mono">llms.txt</code>. Enter a domain to validate what they actually get —
the rendered markdown, the curated link map, robots rules, and a list of fixes.
</EmptyStateDescription>
<EmptyStateActions>
{EXAMPLES.map(example => (
<AgentViewLinkButton key={example} url={example} />
))}
</EmptyStateActions>
</EmptyState>
)
}
/** Example trigger kept separate so tests can reach it by role. */
function AgentViewLinkButton({ url }: { url: string }) {
const [location, navigate] = useLocation()
return (
<Button
variant="secondary"
onClick={() => navigate(`${location.split('?')[0]}?url=${encodeURIComponent(url)}`)}
>
<Icon name="external-link" size={13} /> {url}
</Button>
)
}
function LoadingCard() {
return (
<Card className="agent-doc">
<div className="stack-sm">
<Skeleton width="40%" height={28} />
<Skeleton width="100%" />
<Skeleton width="92%" />
<Skeleton width="96%" />
<Skeleton width="60%" />
</div>
</Card>
)
}
function BrowseError({ error, onRetry }: { error: Error; onRetry: () => void }) {
const code = error instanceof ApiError ? error.code : 'unknown'
const siteFailed = code === 'unavailable' || code === 'timeout'
return (
<Alert variant={siteFailed ? 'warning' : 'destructive'} icon={<Icon name={siteFailed ? 'warning' : 'alert'} size={18} />}>
<AlertTitle>{siteFailed ? 'The site did not answer' : 'Inspection failed'}</AlertTitle>
<AlertDescription>
{siteFailed
? 'The server could not fetch this URL within the time limit — it may be down, very slow, or blocking the fetch.'
: error.message}
</AlertDescription>
<div className="row-xs" style={{ marginTop: 'var(--space-3)' }}>
<Button variant="secondary" onClick={onRetry}>
<Icon name="refresh" size={13} /> Try again
</Button>
</div>
</Alert>
)
}
/* ── The snapshot ───────────────────────────────────────────────────── */
function SnapshotView({
snapshot,
tab,
onTab,
onNavigate,
onRefresh,
}: {
snapshot: Snapshot
tab: string
onTab: (value: string) => void
onNavigate: (href: string) => void
onRefresh: () => void
}) {
const files = snapshot.files
const llmsFound = [files.llmsTxt, files.llmsFullTxt, files.wellKnownLlmsTxt].some(file => file?.status && file.status < 400)
const counts = countSeverities(snapshot.checks)
return (
<div className="stack-md">
<div className="flex-between agent-head" style={{ flexWrap: 'wrap', gap: 'var(--space-3)' }}>
<div className="row-sm" style={{ alignItems: 'center', flexWrap: 'wrap' }}>
<StatusBadge status={snapshot.status} />
<span className="mono agent-url">{snapshot.finalUrl}</span>
{snapshot.requestedUrl !== snapshot.finalUrl && (
<Badge variant="outline">redirected from {snapshot.requestedUrl}</Badge>
)}
<span className="muted" style={{ fontSize: 'var(--text-xs)' }}>
{snapshot.durationMs} ms · {snapshot.contentType.split(';')[0] || 'unknown type'}
</span>
</div>
<Button variant="ghost" onClick={onRefresh}>
<Icon name="refresh" size={13} /> Re-run
</Button>
</div>
<Tabs value={tab} onValueChange={onTab}>
<TabsList>
<TabsTrigger value="markdown">Markdown</TabsTrigger>
<TabsTrigger value="source">Source</TabsTrigger>
<TabsTrigger value="llms">
llms.txt{llmsFound ? '' : ' ·'}
</TabsTrigger>
<TabsTrigger value="robots">robots.txt</TabsTrigger>
<TabsTrigger value="meta">Meta</TabsTrigger>
<TabsTrigger value="checks">
Checks{counts.total > 0 ? ` (${counts.total})` : ''}
</TabsTrigger>
</TabsList>
<TabsContent value="markdown">
<div className="stack-sm">
<div className="flex-between muted agent-note">
<span>
{snapshot.markdown.length.toLocaleString()} characters ·{' '}
{snapshot.page ? `${snapshot.page.linkCount} links` : 'non-HTML document'} — links stay
in the viewer, like an agent re-entering a page.
</span>
{snapshot.markdownTruncated && <Badge variant="destructive">truncated</Badge>}
</div>
{snapshot.markdown.trim() === '' ? (
<Alert variant="warning" icon={<Icon name="warning" size={18} />}>
<AlertTitle>No content without JavaScript</AlertTitle>
<AlertDescription>
The server sent {snapshot.page ? `${snapshot.page.scriptCount} script tags and ` : ''}
no readable body text. An agent sees an empty page here.
</AlertDescription>
</Alert>
) : (
<Card className="agent-doc">
<MarkdownView source={snapshot.markdown} onNavigate={onNavigate} className="markdown-body" />
</Card>
)}
</div>
</TabsContent>
<TabsContent value="source">
<RawBlock
text={snapshot.markdown}
note={`${snapshot.markdown.length.toLocaleString()} characters of markdown`}
/>
</TabsContent>
<TabsContent value="llms">
<div className="stack-md">
<FileCard label="/llms.txt" file={files.llmsTxt} spec onNavigate={onNavigate} />
<FileCard label="/llms-full.txt" file={files.llmsFullTxt} onNavigate={onNavigate} />
<FileCard label="/.well-known/llms.txt" file={files.wellKnownLlmsTxt} onNavigate={onNavigate} />
</div>
</TabsContent>
<TabsContent value="robots">
<FileCard label="/robots.txt" file={files.robotsTxt} onNavigate={onNavigate} />
</TabsContent>
<TabsContent value="meta">
<MetaView snapshot={snapshot} />
</TabsContent>
<TabsContent value="checks">
<ChecksView checks={snapshot.checks} />
</TabsContent>
</Tabs>
</div>
)
}
/* ── Pieces ─────────────────────────────────────────────────────────── */
function StatusBadge({ status }: { status: number }) {
const variant = status < 300 ? 'positive' : status < 400 ? 'primary' : 'destructive'
return (
<Badge variant={variant}>
{status} {status < 300 ? 'OK' : status < 400 ? 'redirect' : 'error'}
</Badge>
)
}
function SeverityBadge({ severity }: { severity: Severity }) {
const variant: Record<Severity, 'positive' | 'secondary' | 'primary' | 'destructive'> = {
ok: 'positive',
info: 'secondary',
warn: 'primary',
error: 'destructive',
}
return <Badge variant={variant[severity]}>{severity}</Badge>
}
function countSeverities(checks: Check[]) {
const counts = { total: checks.length, warn: 0, error: 0 }
for (const check of checks) {
if (check.severity === 'warn') counts.warn++
if (check.severity === 'error') counts.error++
}
return counts
}
function RawBlock({ text, note }: { text: string; note: string }) {
const [copied, setCopied] = useState(false)
const copy = async () => {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
// clipboard unavailable (permissions); the text is selectable anyway
}
}
return (
<Card>
<div className="flex-between" style={{ padding: 'var(--space-3) var(--space-4)', borderBottom: '1px solid var(--border)' }}>
<span className="muted" style={{ fontSize: 'var(--text-xs)' }}>{note}</span>
<Button variant="ghost" onClick={copy}>
<Icon name={copied ? 'check' : 'copy'} size={13} /> {copied ? 'Copied' : 'Copy'}
</Button>
</div>
<pre className="agent-raw mono">{text}</pre>
</Card>
)
}
function FileCard({
label,
file,
spec,
onNavigate,
}: {
label: string
file: FileResult | null
spec?: boolean
onNavigate?: (href: string) => void
}) {
return (
<Card>
<CardHeader>
<div className="flex-between" style={{ flexWrap: 'wrap', gap: 'var(--space-2)' }}>
<div className="row-sm" style={{ alignItems: 'center' }}>
<Icon name="file" size={15} />
<CardTitle>{label}</CardTitle>
<FileBadge file={file} />
</div>
{file?.url && (
<a className="mono muted agent-file-url" href={file.url} target="_blank" rel="noreferrer">
{file.url} <Icon name="external-link" size={11} />
</a>
)}
</div>
<CardDescription>
{file === null
? 'Not fetched (the request failed before an answer).'
: spec
? 'The llmstxt.org convention: a curated, agent-first map of the site.'
: undefined}
</CardDescription>
</CardHeader>
{file?.status && file.status < 400 ? (
<div className="stack-sm">
{file.llmsDoc && <LlmsDocView doc={file.llmsDoc} onNavigate={onNavigate} />}
<details>
<summary className="muted" style={{ fontSize: 'var(--text-xs)', cursor: 'pointer' }}>
Raw {label}
</summary>
<pre className="agent-raw mono">{file.content}</pre>
</details>
</div>
) : (
<p className="muted" style={{ margin: 0, fontSize: 'var(--text-sm)' }}>
{file?.error
? `Unreachable: ${file.error}`
: file?.status
? `Not published (HTTP ${file.status}).${spec ? ' Agents fall back to parsing the HTML — adding one is the single highest-impact fix.' : ''}`
: 'No response recorded.'}
</p>
)}
</Card>
)
}
function FileBadge({ file }: { file: FileResult | null }) {
if (file === null) return <Badge variant="destructive">failed</Badge>
if (file.status < 300) return <Badge variant="positive">found</Badge>
if (file.status < 400) return <Badge variant="primary">redirect</Badge>
if (file.status === 404) return <Badge variant="secondary">missing</Badge>
return <Badge variant="destructive">HTTP {file.status}</Badge>
}
/** A parsed llms.txt: title, summary, and its curated sections. */
function LlmsDocView({ doc, onNavigate }: { doc: LlmsDoc; onNavigate?: (href: string) => void }) {
return (
<div className="agent-llms">
{doc.title && <h3 className="agent-llms-title">{doc.title}</h3>}
{doc.description && <blockquote className="agent-llms-quote">{doc.description}</blockquote>}
{doc.sections.map((section, index) => (
<div key={index} className="agent-llms-section">
{section.heading && <h4>{section.heading}</h4>}
{section.description && <p className="muted">{section.description}</p>}
<ul>
{section.links.map((link, linkIndex) => (
<li key={linkIndex}>
<a
href={link.url}
onClick={event => {
if (!onNavigate || !/^https?:\/\//i.test(link.url)) return
event.preventDefault()
onNavigate(link.url)
}}
>
{link.title || link.url}
</a>
{link.description && <span className="muted"> — {link.description}</span>}
</li>
))}
</ul>
</div>
))}
{doc.linkCount === 0 && <p className="muted">No links parsed from this file.</p>}
</div>
)
}
function MetaView({ snapshot }: { snapshot: Snapshot }) {
const page = snapshot.page
const rows: [string, string][] = page
? [
['Title', page.title || '—'],
['Description', page.description || '—'],
['Canonical', page.canonical || '—'],
['Meta robots', page.robotsMeta || 'not set'],
['Language', page.lang || '—'],
['H1', page.h1 || '—'],
['Links', `${page.linkCount} followable · ${page.deadLinks} dead`],
['Scripts', String(page.scriptCount)],
['Images', String(page.imageCount)],
['Visible text', `${page.textLength.toLocaleString()} chars · ${page.wordCount.toLocaleString()} words`],
]
: [['Content type', snapshot.contentType || '—']]
return (
<Card>
<dl className="agent-meta">
{rows.map(([term, value]) => (
<div key={term} className="agent-meta-row">
<dt className="muted">{term}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
</Card>
)
}
function ChecksView({ checks }: { checks: Check[] }) {
const counts = countSeverities(checks)
return (
<Card>
<div className="row-sm agent-checks-summary" style={{ flexWrap: 'wrap' }}>
<Badge variant="destructive">{counts.error} errors</Badge>
<Badge variant="primary">{counts.warn} warnings</Badge>
<Badge variant="secondary">
{checks.filter(check => check.severity === 'info').length} notes
</Badge>
<Badge variant="positive">
{checks.filter(check => check.severity === 'ok').length} passed
</Badge>
</div>
<ul className="agent-checks">
{checks.map(check => (
<li key={check.id} className="agent-check">
<SeverityBadge severity={check.severity} />
<div>
<div className="agent-check-label">{check.label}</div>
<div className="agent-check-detail muted">{check.detail}</div>
</div>
</li>
))}
</ul>
</Card>
)
}
+2 -1
View File
@@ -99,7 +99,8 @@
}
/* ── Dark Palette (Exact lab() Specification) ────────────────── */
:root:not([data-theme='light']),
/* Dark theme — explicit selectors only. With no data-theme attribute the
:root default palette (identical to dark) already applies. */
:root[data-theme='dark'],
.dark {
color-scheme: dark;