diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..f90e5bd4 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +/src/js/13-agent-handoff.js @redpanda-data/documentation diff --git a/.github/workflows/validate-build.yml b/.github/workflows/validate-build.yml index df3c735f..e4f1bcd2 100644 --- a/.github/workflows/validate-build.yml +++ b/.github/workflows/validate-build.yml @@ -42,6 +42,8 @@ jobs: - name: Install dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' run: npm ci + - name: Test page actions + run: npm run test:markdown-dropdown - name: Cache generated files uses: actions/cache@v4 with: diff --git a/package.json b/package.json index fea318c2..f3f7367f 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "vinyl-source-stream": "^2.0.0" }, "scripts": { + "test:markdown-dropdown": "node --test tests/markdown-dropdown/*.test.js", "test:headless": "node tests/bloblang-playground/test-runner.js", "test:playground": "npm run build:wasm && npm run test:headless", "test:interactive": "node tests/bloblang-interactive/test-runner.js", diff --git a/preview-src/index.adoc b/preview-src/index.adoc index ad9d7ea0..c04d84a1 100644 --- a/preview-src/index.adoc +++ b/preview-src/index.adoc @@ -6,6 +6,7 @@ :!table-caption: :page-pagination: :page-has-markdown: +:page-agent-handoff: {description} diff --git a/src/helpers/has-agent-handoff.js b/src/helpers/has-agent-handoff.js new file mode 100644 index 00000000..ca3b8f9a --- /dev/null +++ b/src/helpers/has-agent-handoff.js @@ -0,0 +1,7 @@ +/** + * Show the agent handoff action only when the page opts in. + */ + +module.exports = ({ data: { root } }) => { + return root.page?.attributes?.['agent-handoff'] !== undefined +} diff --git a/src/js/13-agent-handoff.js b/src/js/13-agent-handoff.js new file mode 100644 index 00000000..cdd8ff6b --- /dev/null +++ b/src/js/13-agent-handoff.js @@ -0,0 +1,130 @@ +;(function (root, factory) { + const agentHandoff = factory() + + if (typeof module === 'object' && module.exports) { + module.exports = agentHandoff + } else { + root.RedpandaDocsAgentHandoff = agentHandoff + } +})(typeof window === 'undefined' ? this : window, function () { + const headingPattern = /^(#{1,6})[ \t]+(.+?)\s*$/ + const fencePattern = /^[ \t]*(`{3,}|~{3,})/ + + function normalizedAnchors (sectionAnchor) { + const rawAnchor = sectionAnchor.replace(/^#/, '') + let decodedAnchor + + try { + decodedAnchor = decodeURIComponent(rawAnchor) + } catch (error) { + decodedAnchor = rawAnchor + } + + const generatedMarkdownAnchor = decodedAnchor.replace(/^_/, '').replace(/_/g, '-') + return generatedMarkdownAnchor === decodedAnchor + ? [decodedAnchor] + : [decodedAnchor, generatedMarkdownAnchor] + } + + function extractMarkdownSection (markdown, sectionAnchor) { + const fullPage = markdown.trim() + if (!sectionAnchor) return fullPage + + const anchorMarkers = normalizedAnchors(sectionAnchor).map((anchor) => `(#${anchor})`) + const lines = fullPage.split('\n') + const headingLines = computeHeadingLines(lines) + const startIndex = lines.findIndex( + (line, index) => headingLines[index] && anchorMarkers.some((marker) => line.includes(marker)) + ) + if (startIndex === -1) return fullPage + + const sectionLevel = lines[startIndex].match(headingPattern)[1].length + const endIndex = lines.findIndex((line, index) => { + if (index <= startIndex) return false + + if (!headingLines[index]) return false + return line.match(headingPattern)[1].length <= sectionLevel + }) + + return lines.slice(startIndex, endIndex === -1 ? undefined : endIndex).join('\n').trim() + } + + function computeHeadingLines (lines) { + let fenceMarker = '' + + return lines.map((line) => { + const fence = line.match(fencePattern) + if (fence) { + const marker = fence[1][0] + if (!fenceMarker) fenceMarker = marker + else if (marker === fenceMarker) fenceMarker = '' + return false + } + + return !fenceMarker && headingPattern.test(line) + }) + } + + function buildAgentHandoffPrompt ({ + componentName = '', + docsOrigin, + markdown, + markdownUrl, + pageTitle, + pageUrl, + sectionAnchor = '', + sectionTitle = '', + }) { + const context = extractMarkdownSection(markdown, sectionAnchor) + const componentExport = componentName + ? new URL(`/${encodeURIComponent(componentName)}-full.txt`, docsOrigin).href + : null + const scope = [ + `- Documentation page: ${pageTitle}`, + sectionTitle ? `- Current section: ${sectionTitle}` : null, + `- Page: ${pageUrl}`, + `- Markdown source: ${markdownUrl}`, + ].filter(Boolean) + const sources = [ + `- Documentation index: ${new URL('/llms.txt', docsOrigin).href}`, + componentExport ? `- Component documentation export: ${componentExport}` : null, + `- Documentation MCP server: ${new URL('/mcp', docsOrigin).href}`, + ].filter(Boolean) + const instructions = [ + "1. Read the current project's agent and contributor instructions before changing anything.", + '2. Inspect the project and identify where this documentation applies. Do not invent Redpanda commands, fields, or behavior.', + '3. If applicable, implement the smallest reversible change and preserve unrelated behavior. If not applicable, explain why and stop.', + '4. You may edit and test local files. Before destructive operations, external mutations, or changes to a live ' + + 'Redpanda environment, show the plan or diff and get my confirmation. Never expose credentials or secrets.', + "5. Run the project's relevant checks and any documented Redpanda validation or diff command.", + '6. Summarize the changes, verification, remaining manual steps, and any missing or conflicting documentation.', + ] + + return `# Apply this Redpanda documentation + +Work in the current project. Determine whether this guidance applies, then make the smallest safe update that keeps the project aligned with the current Redpanda pattern. + +## Scope + +${scope.join('\n')} + +## Authoritative Redpanda context + +${sources.join('\n')} + +## Instructions + +${instructions.join('\n')} + +## Documentation context + +--- BEGIN CURRENT DOCUMENTATION --- +${context} +--- END CURRENT DOCUMENTATION ---` + } + + return { + buildAgentHandoffPrompt, + extractMarkdownSection, + } +}) diff --git a/src/js/14-markdown-dropdown.js b/src/js/14-markdown-dropdown.js index b9728e86..c04784b5 100644 --- a/src/js/14-markdown-dropdown.js +++ b/src/js/14-markdown-dropdown.js @@ -34,6 +34,7 @@ const menu = dropdown.querySelector('.markdown-dropdown-menu') const items = dropdown.querySelectorAll('.markdown-dropdown-item') const markdownUrl = dropdown.dataset.markdownUrl + const componentName = dropdown.dataset.componentName if (!toggle || !menu || !markdownUrl) { return @@ -60,6 +61,14 @@ setTimeout(function () { setOpen(false) }, 2500) + } else if (action === 'copy-agent') { + handleCopyAgent(markdownUrl, componentName, item).then(function (didCopy) { + if (didCopy) { + setTimeout(function () { + setOpen(false) + }, 2500) + } + }) } else if (action === 'view') { handleView(markdownUrl) setOpen(false) @@ -155,6 +164,66 @@ ) } + function flashCopyStatus (button, message) { + const status = button.querySelector('[data-agent-handoff-status]') + if (status) status.textContent = message + + button.classList.add('clicked') + // Force reflow so the animation can restart. + button.offsetHeight // eslint-disable-line no-unused-expressions + button.classList.remove('clicked') + } + + /** + * Copy a complete agent handoff with the current documentation section inline. + */ + function handleCopyAgent (markdownUrl, componentName, button) { + const buildAgentHandoffPrompt = window.RedpandaDocsAgentHandoff?.buildAgentHandoffPrompt + if (typeof buildAgentHandoffPrompt !== 'function') { + console.error('Could not copy agent handoff: prompt builder is unavailable.') + flashCopyStatus(button, 'Could not copy. Try again.') + return Promise.resolve(false) + } + + return window + .fetch(markdownUrl) + .then(function (response) { + if (!response.ok) throw new Error(`Failed to fetch documentation (${response.status})`) + return response.text() + }) + .then(function (markdown) { + const pageUrl = window.location.href + const absoluteMarkdownUrl = new URL(markdownUrl, window.location.origin).href + const pageTitle = document.querySelector('h1.page')?.textContent?.trim() || document.title + const sectionAnchor = window.location.hash + const sectionId = sectionAnchor.replace(/^#/, '') + const sectionTitle = document.getElementById(sectionId)?.textContent?.trim() || '' + const prompt = buildAgentHandoffPrompt({ + componentName, + docsOrigin: window.location.origin, + markdown, + markdownUrl: absoluteMarkdownUrl, + pageTitle, + pageUrl, + sectionAnchor, + sectionTitle, + }) + + return window.navigator.clipboard.writeText(prompt) + }) + .then( + function () { + flashCopyStatus(button, 'Copied!') + return true + }, + function (error) { + console.error('Could not copy agent handoff:', error) + flashCopyStatus(button, 'Could not copy. Try again.') + return false + } + ) + } + /** * Handle view in new tab */ diff --git a/src/partials/markdown-dropdown.hbs b/src/partials/markdown-dropdown.hbs index 357dba00..487b132e 100644 --- a/src/partials/markdown-dropdown.hbs +++ b/src/partials/markdown-dropdown.hbs @@ -1,5 +1,9 @@ {{#if (has-markdown)}} -
+
+ {{#if (has-agent-handoff)}} + + {{/if}} +