Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/docs-negotiation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -226,9 +226,16 @@ jobs:
if: always() && steps.build.outcome == 'success'
run: |
mkdir -p apps/docs/test-results
FEEDBACK_TLS_DIR=$(mktemp -d)
export DOCS_FEEDBACK_TEST_CERT="$FEEDBACK_TLS_DIR/cert.pem"
export DOCS_FEEDBACK_TEST_KEY="$FEEDBACK_TLS_DIR/key.pem"
openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj '/CN=127.0.0.1' -addext 'subjectAltName=IP:127.0.0.1' -keyout "$DOCS_FEEDBACK_TEST_KEY" -out "$DOCS_FEEDBACK_TEST_CERT" 2>/dev/null
export NODE_EXTRA_CA_CERTS="$DOCS_FEEDBACK_TEST_CERT"
export DOCS_FEEDBACK_WEBHOOK_URL=https://127.0.0.1:3443/feedback
export DOCS_FEEDBACK_WEBHOOK_TOKEN=$(openssl rand -hex 32)
bun run --cwd apps/docs start --port 3001 >apps/docs/test-results/docs-server.log 2>&1 &
DOCS_SERVER_PID=$!
trap 'kill "$DOCS_SERVER_PID" 2>/dev/null || true' EXIT
trap 'kill "$DOCS_SERVER_PID" 2>/dev/null || true; rm -rf "$FEEDBACK_TLS_DIR"' EXIT
STATUS=0
bun run --cwd apps/docs test:negotiation:e2e || STATUS=1
bun run --cwd apps/docs scripts/test-docs-interfaces-e2e.ts || STATUS=1
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/DOMAIN_MOVE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

The site currently targets `docs.sim.ai`. A move to `sim.ai/docs` is separate infrastructure work, not completed by the docs redesign. Changing `NEXT_PUBLIC_DOCS_URL` alone is insufficient.

The platform's temporary 307 redirects send `/docs` to the introduction and `/docs/*` to the matching path on `docs.sim.ai`, preserving query parameters. Remove these outgoing redirects when mounting the docs at `/docs`; they are not the routing layer for the future move.

## Required changes

- Build Next.js with `basePath: '/docs'`; an asset prefix alone does not mount the app. Keep canonical catalog IDs such as `/introduction` independent of the public prefix.
Expand Down
13 changes: 13 additions & 0 deletions apps/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,16 @@ The [machine access guide](MACHINE_ACCESS.md) documents Markdown, JSON, MCP, sea
From the repository root, run the required lint, type, audit, and test gates in the development guidelines. From this directory, `bun run type-check` compiles MDX and checks the docs workspace, and `bun run build` validates the production site. Inspect affected page types on desktop and a narrow viewport, and check internal links and existing anchors after heading changes.

The docs HTTP workflow also checks sidebar navigation, API language switching, and copying Markdown on desktop and mobile layouts. It uploads screenshots and a JSON report with the HTTP regression artifacts. These checks run against the built site in CI; no visitor telemetry is collected. Use Vercel's build logs and usage reports to compare deployment cost and duration.

## Reader feedback

Votes are submitted through `/api/feedback` to an authenticated Sim webhook. Configure
`DOCS_FEEDBACK_WEBHOOK_URL` and `DOCS_FEEDBACK_WEBHOOK_TOKEN` only on the server. The
endpoint returns unavailable when either is absent. Limit POST requests to this path
at the deployment edge before enabling it (10 requests per IP per minute).

The workflow receives `submission_id`, `page`, `helpful`, `comment`, and `received_at`.
Use `submission_id` as the webhook deduplication field and a unique table column for
upsert. Retries of an unchanged submission reuse its ID. The acknowledgment confirms
queue acceptance; workflow failures remain visible in Sim execution logs. No email,
IP address, or user identity is included in the stored feedback payload.
95 changes: 95 additions & 0 deletions apps/docs/app/api/feedback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { createLogger } from '@sim/logger'
import { z } from 'zod'
import { getPage } from '@/lib/content/catalog'
import { pagePathSchema } from '@/lib/http/pages'

const logger = createLogger('DocsFeedback')
const feedbackSchema = z
.object({
submissionId: z.uuid(),
page: pagePathSchema,
helpful: z.boolean(),
comment: z.string().trim().max(2000),
})
.strict()
const MAX_BODY_BYTES = 12_288

export async function POST(request: Request) {
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
const headers = { 'Cache-Control': 'no-store' }
const fail = (error: string, status: number) => Response.json({ error }, { status, headers })
const requestUrl = new URL(request.url)
const host = request.headers.get('host')
if (!host || request.headers.get('origin') !== `${requestUrl.protocol}//${host}`) {
return fail('Feedback must be submitted from this site.', 403)
}
if (request.headers.get('content-type')?.split(';')[0].trim() !== 'application/json') {
return fail('Expected JSON.', 415)
}
const reader = request.body?.getReader()
if (!reader) return fail('Feedback is required.', 400)
let text = ''
let bytes = 0
const decoder = new TextDecoder()
try {
while (true) {
const chunk = await reader.read()
if (chunk.done) break
bytes += chunk.value.byteLength
if (bytes > MAX_BODY_BYTES) {
await reader.cancel()
return fail('Feedback is too long.', 413)
}
text += decoder.decode(chunk.value, { stream: true })
}
text += decoder.decode()
} catch {
return fail('Could not read feedback.', 400)
} finally {
reader.releaseLock()
}
let input: z.infer<typeof feedbackSchema>
try {
input = feedbackSchema.parse(JSON.parse(text))
} catch {
return fail('Check your feedback and try again.', 400)
}
const page = getPage(input.page.replace(/\/$/, '') || '/')
if (!page || page.url === '/') return fail('Documentation page not found.', 404)
const endpoint = process.env.DOCS_FEEDBACK_WEBHOOK_URL
const token = process.env.DOCS_FEEDBACK_WEBHOOK_TOKEN
if (!endpoint || !token) return fail('Feedback is temporarily unavailable.', 503)
try {
const url = new URL(endpoint)
if (url.protocol !== 'https:') throw new Error('Invalid feedback configuration')
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({
submission_id: input.submissionId,
page: page.url,
helpful: input.helpful,
comment: input.comment,
received_at: new Date().toISOString(),
}),
signal: AbortSignal.timeout(15_000),
redirect: 'error',
})
if (!response.ok) {
logger.warn('Feedback intake rejected submission', { status: response.status })
return fail('Could not send feedback. Please try again.', 502)
}
const receipt: unknown = await response.json()
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
if (
!receipt ||
typeof receipt !== 'object' ||
!('message' in receipt) ||
receipt.message !== 'Webhook processed'
) {
return fail('Could not confirm feedback. Please try again.', 502)
}
return Response.json({ accepted: true }, { status: 202, headers })
} catch {
logger.warn('Feedback intake unavailable')
return fail('Could not send feedback. Please try again.', 503)
}
}
26 changes: 3 additions & 23 deletions apps/docs/components/docs/article.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import type { ReactNode } from 'react'
import { ArrowLeft, ArrowRight, SquareArrowUpRight, ThumbsDown, ThumbsUp } from '@sim/emcn/icons'
import { ArrowLeft, ArrowRight, SquareArrowUpRight } from '@sim/emcn/icons'
import Link from 'next/link'
import { ContentInteractions } from '@/components/docs/content-interactions'
import { Feedback } from '@/components/docs/feedback'
import { PageActions } from '@/components/docs/page-actions'
import { IntegrationIcon } from '@/components/ui/integration-icon'
import { getNavigation, getNeighbours } from '@/lib/content/catalog'
import { findNavigationGroup } from '@/lib/content/navigation'
import type { DocsPage } from '@/lib/content/types'
import { DOCS_BASE_URL } from '@/lib/urls'

interface ArticleProps {
page: DocsPage
Expand All @@ -24,8 +24,6 @@ export function Article({ page, children, aside }: ArticleProps) {
? `apps/sim/lib/api/contracts/v2`
: `apps/docs/content/docs/${page.sourcePath}`
const editUrl = `https://github.com/simstudioai/sim/${page.operation ? 'tree' : 'edit'}/staging/${source}`
const feedbackUrl = (helpful: boolean) =>
`https://github.com/simstudioai/sim/issues/new?${new URLSearchParams({ title: `Docs feedback: ${page.title}`, body: `Page: ${DOCS_BASE_URL}${page.url}\n\n${helpful ? 'What helped:' : 'What could be improved:'}\n` })}`
return (
<main
id='main-content'
Expand Down Expand Up @@ -53,25 +51,7 @@ export function Article({ page, children, aside }: ArticleProps) {
</header>
<article className='min-w-0'>{children}</article>
<div className='mt-12 flex flex-wrap items-center justify-between gap-4 border-[var(--docs-border)] border-t pt-5 text-[var(--docs-muted)] text-docs-caption'>
<div className='flex items-center gap-2'>
<span>Was this helpful?</span>
<a
href={feedbackUrl(true)}
aria-label='Share positive feedback on GitHub'
title='Share feedback on GitHub'
className='before:-inset-2 relative flex size-7 items-center justify-center rounded-full before:absolute hover:bg-[var(--docs-hover)]'
>
<ThumbsUp className='size-4' />
</a>
<a
href={feedbackUrl(false)}
aria-label='Suggest an improvement on GitHub'
title='Suggest an improvement on GitHub'
className='before:-inset-2 relative flex size-7 items-center justify-center rounded-full before:absolute hover:bg-[var(--docs-hover)]'
>
<ThumbsDown className='size-4' />
</a>
</div>
<Feedback key={page.url} page={page.url} />
<a href={editUrl} className='flex items-center gap-1.5 hover:text-[var(--docs-heading)]'>
Suggest an edit
<SquareArrowUpRight className='size-4' />
Expand Down
139 changes: 139 additions & 0 deletions apps/docs/components/docs/feedback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
'use client'

import { useRef, useState } from 'react'
import { ThumbsDown, ThumbsUp } from '@sim/emcn/icons'
import { generateId } from '@sim/utils/id'
import { QueryClient, QueryClientProvider, useMutation } from '@tanstack/react-query'
import { DocsButton } from '@/components/docs/primitives'

interface FeedbackProps {
page: string
}

export function Feedback({ page }: FeedbackProps) {
const [client] = useState(() => new QueryClient())
return (
<QueryClientProvider client={client}>
<FeedbackForm key={page} page={page} />
</QueryClientProvider>
)
}

function FeedbackForm({ page }: FeedbackProps) {
const [helpful, setHelpful] = useState<boolean | null>(null)
const [comment, setComment] = useState('')
const pendingSubmission = useRef<{
submissionId: string
helpful: boolean
comment: string
} | null>(null)
const submission = useMutation({
retry: false,
mutationFn: async (payload: { submissionId: string; helpful: boolean; comment: string }) => {
const response = await fetch('/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, page }),
signal: AbortSignal.timeout(20_000),
})
if (!response.ok)
throw new Error(
response.status === 429
? 'Too many submissions. Please try again in a minute.'
: 'Could not send feedback. Please try again.'
)
const result: unknown = await response.json()
if (
!result ||
typeof result !== 'object' ||
!('accepted' in result) ||
result.accepted !== true
) {
throw new Error('Could not confirm feedback. Please try again.')
}
},
})
function submit(vote: boolean) {
if (submission.isPending) return
const text = vote ? '' : comment.trim()
if (
!pendingSubmission.current ||
pendingSubmission.current.helpful !== vote ||
pendingSubmission.current.comment !== text
) {
pendingSubmission.current = { submissionId: generateId(), helpful: vote, comment: text }
}
submission.mutate(pendingSubmission.current)
}
if (submission.isSuccess) return <p role='status'>Thanks for your feedback.</p>
return (
<form
className='min-w-0 space-y-3'
onSubmit={(event) => {
event.preventDefault()
if (helpful !== null && !submission.isPending) submit(helpful)
}}
>
<div className='flex items-center gap-2'>
<span>Was this helpful?</span>
<DocsButton
type='button'
aria-label='Yes, this was helpful'
aria-pressed={helpful === true}
disabled={submission.isPending}
iconOnly
className='size-11 aria-pressed:bg-[var(--docs-selected)]'
onClick={() => {
setHelpful(true)
submission.reset()
submit(true)
}}
>
<ThumbsUp className='size-4' />
</DocsButton>
<DocsButton
type='button'
aria-label='No, this could be improved'
aria-pressed={helpful === false}
disabled={submission.isPending}
iconOnly
className='size-11 aria-pressed:bg-[var(--docs-selected)]'
onClick={() => {
setHelpful(false)
submission.reset()
}}
>
<ThumbsDown className='size-4' />
</DocsButton>
</div>
{helpful === false && (
<div className='space-y-2'>
<label className='block' htmlFor='docs-feedback-comment'>
What could be better? (optional)
</label>
<textarea
id='docs-feedback-comment'
aria-describedby='docs-feedback-privacy'
value={comment}
maxLength={2000}
rows={3}
disabled={submission.isPending}
onChange={(event) => setComment(event.target.value)}
className='w-full min-w-0 resize-y rounded-lg border border-[var(--docs-border)] bg-[var(--docs-canvas)] p-3 text-[var(--docs-heading)] text-docs-body focus-visible:outline-2 focus-visible:outline-[var(--docs-link)] focus-visible:outline-offset-2'
placeholder='Tell us what was missing or unclear.'
/>
<p id='docs-feedback-privacy'>Please don’t include private information.</p>
<DocsButton
type='submit'
disabled={submission.isPending}
className='h-11 border border-[var(--docs-border)]'
>
{submission.isPending ? 'Sending…' : 'Send feedback'}
</DocsButton>
</div>
)}
{submission.isPending && helpful === true && <p role='status'>Sending…</p>}
{submission.isError && <p role='alert'>{submission.error.message}</p>}
</form>
)
}
Loading
Loading