Skip to content

Commit b313fcc

Browse files
committed
fix(docs): preserve retrieval excerpts and reduce reading overhead
1 parent 57826a6 commit b313fcc

11 files changed

Lines changed: 347 additions & 25 deletions

File tree

‎.github/workflows/docs-negotiation.yml‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ jobs:
9696
echo "DOCS_BENCHMARK_REVISION=$REVISION" >> "$GITHUB_ENV"
9797
bunx --no-install playwright install --with-deps chromium
9898
- name: Build measured production docs
99+
id: build
99100
if: matrix.target == 'build'
100101
run: bun run --cwd apps/docs build
101102
- name: Measure navigation and Lighthouse sequentially
@@ -128,6 +129,16 @@ jobs:
128129
name: docs-benchmark-${{ matrix.target }}
129130
path: test-results/benchmark/
130131
retention-days: 14
132+
- name: Package measured production preview
133+
if: always() && inputs.preview_artifact && steps.build.outcome == 'success'
134+
run: tar -czf docs-preview-build.tar.gz --exclude='.next/cache' -C apps/docs .next .content
135+
- name: Upload measured production preview
136+
if: always() && inputs.preview_artifact && steps.build.outcome == 'success'
137+
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
138+
with:
139+
name: docs-preview-build
140+
path: docs-preview-build.tar.gz
141+
retention-days: 3
131142

132143
http:
133144
if: github.event_name != 'workflow_dispatch' || !inputs.benchmark

‎apps/docs/ARCHITECTURE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,6 @@ The implementation was checked against the installed Next.js 16.4.1 docs and cur
6767

6868
Review the migration report first, then the library gallery and representative pages, then the HTTP/browser reports. Run a production build without embedding credentials to verify the local fallback. Production search index publication remains a separate deployment step and must pass its freshness check.
6969

70-
This change preserves the docs workspace, origin, deployment identity, product generators, and content paths. Reverting the docs replacement commit and its lockfile/workflow changes restores the prior implementation; it requires no database migration or product-data rollback. Keep the PR in draft until the visual and behavior evidence has been reviewed.
70+
This change preserves the docs workspace, origin, deployment identity, product generators, and content paths. Restoring the previous docs deployment requires no schema migration or product-data rollback. Search publication changes `docs_embeddings` separately: before publishing, back up that table and record its paired docs deployment and source revision. Rollback must hold queued publishers, restore the paired deployment and index, and check freshness and citations from the restored source. The workflow does not create this backup automatically and checks out current `main`; ordinary dispatch cannot select an older publisher. Keep the PR in draft until the visual and behavior evidence has been reviewed.
7171

7272
The requested move to `https://sim.ai/docs` requires a coordinated path-aware build and infrastructure cutover. See [the domain migration plan](migration/domain-move.md); the current build remains on `docs.sim.ai` until that work is verified.

‎apps/docs/MACHINE_ACCESS.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ All three tools advertise read-only behavior. Unknown pages produce tool errors;
4747

4848
Basic search uses a server-only index generated atomically with the local compiled catalog and needs no database or embedding credentials. Markdown parsing and text normalization happen during content compilation; requests read precomputed chunks. The generated index is capped at 64 MiB and its catalog fingerprint is validated before serving results. When `DATABASE_URL` is present, bounded PostgreSQL keyword search can supplement it; `OPENAI_API_KEY` additionally enables vector search using the existing 1536-dimensional `text-embedding-3-small` table contract. Unavailable providers fall back to lexical results. Results with old content hashes or nonexistent heading targets are excluded.
4949

50+
Search chunks contain at most 6,000 characters, including continuation context. Paragraphs and code blocks stay intact when they fit. Larger code blocks become explicitly labeled excerpts with the original language, complete fences and unchanged code lines; an excerpt is not necessarily a complete JSON value or executable program. Continuations repeat the page and heading, and table excerpts repeat their column headers while retaining oversized cell text in fragments. Content that cannot fit safely, such as one oversized code line or an oversized nested code block, stops compilation with the page and heading in the error instead of discarding content.
51+
5052
From `apps/docs`:
5153

5254
```sh

‎apps/docs/components/ui/scroll-region.tsx‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ export function ScrollRegion({ label, className, children, ...props }: ScrollReg
1919
const observer = new ResizeObserver(updateFocusability)
2020
observer.observe(element)
2121
if (element.firstElementChild) observer.observe(element.firstElementChild)
22-
updateFocusability()
2322
return () => observer.disconnect()
2423
}, [])
2524
return (

‎apps/docs/hooks/queries/search.ts‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
11
'use client'
22

33
import { useQuery } from '@tanstack/react-query'
4-
import { array, literal, object, string } from 'zod/mini'
4+
import { z } from 'zod'
55

66
const SEARCH_STALE_TIME = 60_000
77
const SEARCH_GC_TIME = 300_000
88
const searchKeys = {
99
all: ['docs-search'] as const,
1010
query: (query: string) => [...searchKeys.all, query] as const,
1111
}
12-
const resultsSchema = array(
13-
object({
14-
id: string(),
15-
type: literal('page'),
16-
url: string(),
17-
content: string(),
18-
breadcrumbs: array(string()),
12+
const resultsSchema = z.array(
13+
z.object({
14+
id: z.string(),
15+
type: z.literal('page'),
16+
url: z.string(),
17+
content: z.string(),
18+
breadcrumbs: z.array(z.string()),
1919
})
2020
)
2121

‎apps/docs/lib/search/chunks.ts‎

Lines changed: 131 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Code, RootContent } from 'mdast'
12
import { toString as markdownToString } from 'mdast-util-to-string'
23
import remarkParse from 'remark-parse'
34
import { unified } from 'unified'
@@ -9,6 +10,133 @@ import { DOCS_BASE_URL } from '@/lib/urls'
910
const CHUNK_CHARACTERS = 6000
1011
const parser = unified().use(remarkParse)
1112

13+
function splitText(value: string, limit: number, location: string): string[] {
14+
const parts: string[] = []
15+
let rest = value
16+
while (rest.length > limit) {
17+
const boundary = Math.max(rest.lastIndexOf(' ', limit), rest.lastIndexOf('\n', limit))
18+
if (boundary <= 0) throw new Error(`${location}: unbreakable text exceeds search chunk budget`)
19+
parts.push(rest.slice(0, boundary).trimEnd())
20+
rest = rest.slice(boundary).trimStart()
21+
}
22+
if (rest || !parts.length) parts.push(rest)
23+
return parts
24+
}
25+
26+
function packLines(lines: string[], limit: number, location: string): string[] {
27+
const parts: string[] = []
28+
let current: string[] = []
29+
let length = 0
30+
for (const line of lines) {
31+
if (line.length > limit) throw new Error(`${location}: a code line exceeds search chunk budget`)
32+
if (current.length && length + 1 + line.length > limit) {
33+
parts.push(current.join('\n'))
34+
current = []
35+
length = 0
36+
}
37+
length += line.length + (current.length ? 1 : 0)
38+
current.push(line)
39+
}
40+
if (current.length) parts.push(current.join('\n'))
41+
return parts
42+
}
43+
44+
function excerptLabel(kind: string, index: number, total: number) {
45+
return `${kind} excerpt (part ${index} of ${total}; continued).\n\n`
46+
}
47+
48+
function codeParts(node: Code, raw: string, limit: number, location: string) {
49+
const opening = /^ {0,3}(`{3,}|~{3,})([^\n]*)/.exec(raw)
50+
const marker = opening?.[1] ?? '```'
51+
const opener = opening ? `${marker}${opening[2]}` : marker
52+
const labelBudget = excerptLabel('Code', raw.length, raw.length).length
53+
const parts = packLines(
54+
node.value.split('\n'),
55+
limit - opener.length - marker.length - labelBudget - 2,
56+
location
57+
)
58+
return parts.map(
59+
(part, index) => `${excerptLabel('Code', index + 1, parts.length)}${opener}\n${part}\n${marker}`
60+
)
61+
}
62+
63+
function tableCells(row: string) {
64+
const cells: string[] = []
65+
let current = ''
66+
let escapes = 0
67+
for (const character of row.trim().replace(/^\|/, '').replace(/\|$/, '')) {
68+
if (character === '|' && escapes % 2 === 0) {
69+
cells.push(current.trim())
70+
current = ''
71+
} else current += character
72+
escapes = character === '\\' ? escapes + 1 : 0
73+
}
74+
cells.push(current.trim())
75+
return cells
76+
}
77+
78+
function tableParts(raw: string, limit: number, location: string) {
79+
const [header, separator, ...rows] = raw.split('\n')
80+
const prefix = `${header}\n${separator}\n`
81+
const rowBudget = limit - prefix.length - excerptLabel('Table', raw.length, raw.length).length
82+
if (rowBudget < 100) throw new Error(`${location}: table header exceeds search chunk budget`)
83+
const expanded = rows.flatMap((row) => {
84+
if (row.length <= rowBudget) return [row]
85+
const cells = tableCells(row)
86+
const cellBudget = Math.floor((rowBudget - 3 * cells.length - 1) / cells.length)
87+
if (cellBudget < 1) throw new Error(`${location}: table columns exceed search chunk budget`)
88+
const fragments = cells.map((cell) => splitText(cell, cellBudget, location))
89+
return Array.from(
90+
{ length: Math.max(...fragments.map((parts) => parts.length)) },
91+
(_, index) =>
92+
`| ${fragments.map((parts) => parts[index] ?? (parts.length === 1 ? parts[0] : '')).join(' | ')} |`
93+
)
94+
})
95+
const parts = packLines(expanded, rowBudget, location)
96+
return parts.map(
97+
(part, index) => `${excerptLabel('Table', index + 1, parts.length)}${prefix}${part}`
98+
)
99+
}
100+
101+
function blockParts(node: RootContent, raw: string, limit: number, location: string) {
102+
if (raw.length <= limit) return [raw]
103+
if (node.type === 'code') return codeParts(node, raw, limit, location)
104+
const lines = raw.split('\n')
105+
if (lines[0]?.includes('|') && /^[\s|:-]+$/.test(lines[1] ?? '') && lines[1]?.includes('-'))
106+
return tableParts(raw, limit, location)
107+
let nestedCode = false
108+
visit(node, 'code', () => {
109+
nestedCode = true
110+
})
111+
if (nestedCode)
112+
throw new Error(`${location}: oversized nested code requires a separate search block`)
113+
return splitText(raw, limit, location)
114+
}
115+
116+
function sectionChunks(text: string, page: DocsPage, heading: DocsHeading): string[] {
117+
if (!text) return []
118+
if (text.length <= CHUNK_CHARACTERS) return [text]
119+
const context = `# ${page.title}\n\n${'#'.repeat(heading.depth)} ${heading.title} (continued)\n\n`
120+
const limit = CHUNK_CHARACTERS - context.length
121+
const location = `${page.url}${heading.id ? `#${heading.id}` : ''}`
122+
if (limit < 100) throw new Error(`${location}: heading context exceeds search chunk budget`)
123+
const chunks: string[] = []
124+
let current = ''
125+
const flush = () => {
126+
if (current) chunks.push(`${chunks.length ? context : ''}${current}`)
127+
current = ''
128+
}
129+
for (const node of parser.parse(text).children) {
130+
const raw = text.slice(node.position?.start.offset, node.position?.end.offset)
131+
for (const part of blockParts(node, raw, limit, location)) {
132+
if (current && current.length + 2 + part.length > limit) flush()
133+
current += `${current ? '\n\n' : ''}${part}`
134+
}
135+
}
136+
flush()
137+
return chunks
138+
}
139+
12140
/** Chunk processed Markdown by AST headings, preserving catalog IDs rather than generating slugs. */
13141
export function* pageSearchChunks(page: DocsPage): Generator<SearchChunk> {
14142
const headingsByTitle = new Map<string, DocsHeading[]>()
@@ -33,9 +161,9 @@ export function* pageSearchChunks(page: DocsPage): Generator<SearchChunk> {
33161
})
34162
for (const [index, section] of sections.entries()) {
35163
const text = page.markdown.slice(section.offset, sections[index + 1]?.offset).trim()
36-
for (let offset = 0; offset < text.length; offset += CHUNK_CHARACTERS) {
164+
for (const [chunkIndex, chunk] of sectionChunks(text, page, section.heading).entries()) {
37165
yield {
38-
id: `${page.id}:${section.heading.id}:${offset / CHUNK_CHARACTERS}`,
166+
id: `${page.id}:${section.heading.id}:${chunkIndex}`,
39167
pageId: page.id,
40168
sourceDocument: page.operation
41169
? `${page.url.slice(1)}.mdx`
@@ -46,7 +174,7 @@ export function* pageSearchChunks(page: DocsPage): Generator<SearchChunk> {
46174
).href,
47175
headerText: section.heading.title,
48176
headerLevel: section.heading.depth,
49-
text: text.slice(offset, offset + CHUNK_CHARACTERS),
177+
text: chunk,
50178
contentHash: page.contentHash,
51179
documentTitle: page.title,
52180
}

‎apps/docs/next.config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const config: NextConfig = {
66
transpilePackages: ['@sim/emcn', '@sim/workflow-renderer'],
77
images: {
88
deviceSizes: [384, 640, 828, 1080, 1440, 1920, 2560],
9+
imageSizes: [32, 48, 64, 96, 128, 256],
910
qualities: [75, 90],
1011
minimumCacheTTL: 60,
1112
localPatterns: [{ pathname: '/_next/static/media/**' }, { pathname: '/static/**' }],

‎apps/docs/scripts/benchmark-readiness.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -941,7 +941,7 @@ async function interactions(browser: Browser, profile: Profile, repetition: numb
941941
.getByRole('button', { name: /^(Expand .*workflow preview|Inspect workflow: .*)$/i })
942942
.first()
943943
await trigger.waitFor({ state: 'visible', timeout: 15_000 })
944-
await trigger.scrollIntoViewIfNeeded()
944+
await trigger.click({ trial: true })
945945
const start = performance.now()
946946
await trigger.click()
947947
const dialog = run.page.getByRole('dialog').first()

‎apps/docs/scripts/test-negotiation-e2e.ts‎

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,20 @@ interface CheckResult {
2121
durationMs: number
2222
contentType?: string
2323
vary?: string
24+
statusCode?: number
2425
error?: string
2526
}
2627

27-
const cases = [
28+
interface RepresentationCase {
29+
name: string
30+
accept: string
31+
expected: 'text/html' | 'text/markdown'
32+
method?: 'GET' | 'HEAD'
33+
statusCode?: number
34+
useHtmlValidator?: boolean
35+
}
36+
37+
const cases: readonly RepresentationCase[] = [
2838
{
2939
name: 'HTML advertises its representation and router cache dimensions',
3040
accept: 'text/html',
@@ -60,6 +70,39 @@ const cases = [
6070
accept: 'text/html;q=0,text/*;q=0.5',
6171
expected: 'text/markdown',
6272
},
73+
{
74+
name: 'HTML HEAD retains representation headers without a body',
75+
accept: 'text/html',
76+
expected: 'text/html',
77+
method: 'HEAD',
78+
},
79+
{
80+
name: 'Markdown HEAD retains representation headers without a body',
81+
accept: 'text/markdown',
82+
expected: 'text/markdown',
83+
method: 'HEAD',
84+
},
85+
{
86+
name: 'Matching HTML validator returns a bodyless 304 with cache dimensions',
87+
accept: 'text/html',
88+
expected: 'text/html',
89+
statusCode: 304,
90+
useHtmlValidator: true,
91+
},
92+
{
93+
name: 'Conditional HTML HEAD returns a bodyless 304',
94+
accept: 'text/html',
95+
expected: 'text/html',
96+
method: 'HEAD',
97+
statusCode: 304,
98+
useHtmlValidator: true,
99+
},
100+
{
101+
name: 'An HTML validator cannot suppress the Markdown representation',
102+
accept: 'text/markdown',
103+
expected: 'text/markdown',
104+
useHtmlValidator: true,
105+
},
63106
] as const
64107

65108
for (let attempt = 0; attempt < 20; attempt += 1) {
@@ -72,24 +115,37 @@ for (let attempt = 0; attempt < 20; attempt += 1) {
72115
}
73116

74117
const checks: CheckResult[] = []
118+
let htmlValidator: string | undefined
75119
for (const entry of cases) {
76120
const started = performance.now()
77121
const result: CheckResult = { name: entry.name, status: 'failed', durationMs: 0 }
78122
try {
123+
const headers = new Headers({ Accept: entry.accept })
124+
if (entry.useHtmlValidator) {
125+
if (!htmlValidator) throw new Error('HTML did not provide a cache validator')
126+
headers.set('If-None-Match', htmlValidator)
127+
}
79128
const response = await fetch(url, {
80-
headers: { Accept: entry.accept },
129+
method: entry.method ?? 'GET',
130+
headers,
81131
signal: AbortSignal.timeout(10000),
82132
})
83-
await response.body?.cancel()
133+
if (entry.method === 'HEAD' || entry.statusCode === 304) {
134+
if ((await response.arrayBuffer()).byteLength !== 0)
135+
throw new Error('HEAD and 304 responses must not contain a body')
136+
} else await response.body?.cancel()
137+
result.statusCode = response.status
84138
result.contentType = response.headers.get('Content-Type') ?? ''
85139
result.vary = response.headers.get('Vary') ?? ''
86140
const fields = result.vary.split(',').map((field) => field.trim().toLowerCase())
87-
if (!response.ok) throw new Error(`Unexpected HTTP status ${response.status}`)
88-
if (!result.contentType.startsWith(entry.expected)) {
141+
if (response.status !== (entry.statusCode ?? 200))
142+
throw new Error(`Unexpected HTTP status ${response.status}`)
143+
if (response.status !== 304 && !result.contentType.startsWith(entry.expected)) {
89144
throw new Error(`Expected ${entry.expected}; received ${result.contentType}`)
90145
}
91146
if (!fields.includes('accept')) throw new Error('The response lost Vary: Accept')
92147
if (entry.expected === 'text/html') {
148+
if (response.status === 200) htmlValidator = response.headers.get('etag') ?? undefined
93149
for (const field of [
94150
'rsc',
95151
'next-router-state-tree',

0 commit comments

Comments
 (0)