llm browser
This commit is contained in:
1 parent
afed023152
commit
a4dbb75195
39 files changed
+3709
-58
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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} />)
|
||||
}
|
||||
@@ -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)) }]
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user