From abb6491b37d3e7f9030169b85f88543ae50a3c23 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 18 Oct 2026 04:28:49 -0500 Subject: [PATCH] fix(api-model) Keep keyword-argument literals unlinked why: Prose code literals link every name that resolves to one member. A keyword argument names a parameter of the call being described, so Ruby's `cancel:` linked to Cancellation#cancel, `clients: true` to Snapshot#clients, and `action:` to an unrelated Workspace member. what: - Add SymbolIndex.linkLiteral, which leaves `name:` and `name: value` plain and links other literals as before - Use it for HTML prose, and skip keyword arguments in Markdown export - Test that keyword arguments stay plain while the bare name still links --- packages/api-model/src/index.ts | 2 +- packages/api-model/src/link.ts | 14 ++++++++++++++ site/src/components/api/ApiDoc.astro | 2 +- site/src/lib/symbol-markdown.ts | 3 ++- site/test/inline-reference-appearance.test.ts | 19 +++++++++++++++++++ 5 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/api-model/src/index.ts b/packages/api-model/src/index.ts index 78809ba65..7716b7981 100644 --- a/packages/api-model/src/index.ts +++ b/packages/api-model/src/index.ts @@ -9,7 +9,7 @@ export { extractJulia } from './languages/julia.ts' export { extractFSharp, extractFSharpArtifact } from './languages/fsharp.ts' export { extractProject } from './project.ts' export { resolvePublicIds } from './reexports.ts' -export { SymbolIndex, parameterId, type LinkTarget, type KeywordHelp } from './link.ts' +export { SymbolIndex, keywordArgument, parameterId, type LinkTarget, type KeywordHelp } from './link.ts' export { tokenizeDoc, docSummaryText, referencesIn, type DocSpan } from './doc/roles.ts' export { writeInventory, readInventory, type InventoryEntry } from './inventory.ts' export { Resolver, notASymbol, toPath, DEFAULT_TEMPLATES, DEFAULT_PRIMARY_MODULES } from './resolver.ts' diff --git a/packages/api-model/src/link.ts b/packages/api-model/src/link.ts index 936de98ef..30105a48a 100644 --- a/packages/api-model/src/link.ts +++ b/packages/api-model/src/link.ts @@ -121,6 +121,9 @@ const KEYWORD_HELP: Record> = { }, } +/** A keyword argument (`cancel:`, `clients: true`) names a parameter, not a member. */ +export const keywordArgument = /^[A-Za-z_]\w*[?!]?:(?:\s|$)/ + interface TypeSpan { text: string link?: LinkTarget @@ -677,6 +680,17 @@ export class SymbolIndex { ) } + /** + * Link the names in a prose code literal. + * + * A keyword argument such as `cancel:` or `clients: true` names a parameter + * of the call being described, not the member elsewhere that shares its + * name, so it stays plain code. + */ + linkLiteral(text: string, context?: ApiSymbol): TypeSpan[] { + return keywordArgument.test(text) ? [{ text }] : this.linkType(text, context) + } + /** * Split a type annotation into linked and literal spans. * diff --git a/site/src/components/api/ApiDoc.astro b/site/src/components/api/ApiDoc.astro index 5d7281e2e..efd973a29 100644 --- a/site/src/components/api/ApiDoc.astro +++ b/site/src/components/api/ApiDoc.astro @@ -50,7 +50,7 @@ const labelParts = (label: string, qualified: boolean) => qualified ? label.spli {span.label} ) } - const parts = span.link ? [] : index.linkType(span.label, context) + const parts = span.link ? [] : index.linkLiteral(span.label, context) const qualified = memberReference.test(span.label) && Boolean(span.link || parts.some((part) => part.link)) return span.link ? ( diff --git a/site/src/lib/symbol-markdown.ts b/site/src/lib/symbol-markdown.ts index 5f52f286d..cc302c121 100644 --- a/site/src/lib/symbol-markdown.ts +++ b/site/src/lib/symbol-markdown.ts @@ -1,7 +1,7 @@ import type { ApiModel, ApiSymbol, Signature, SourceApiModel, SymbolIndex } from '@libtmux/api-model' import { fromMarkdown } from 'mdast-util-from-markdown' import { toMarkdown } from 'mdast-util-to-markdown' -import { membersOf, memberSignals, memberTier, moduleOf, qualifiedNameOf } from '@libtmux/api-model' +import { keywordArgument, membersOf, memberSignals, memberTier, moduleOf, qualifiedNameOf } from '@libtmux/api-model' import mentions from '../data/mentions.json' import { PORT_NAME, referenceAlternatives } from './api-models' import { apiCommandFacts, apiCommandRelatedLinks, apiEntryFields, apiMemberGroups, apiMethodDetails, anonymousDeclarationLabel, hasScalarMembers, nativeSignatureText, parameterBindingText, signatureMultiline, signatureParameterText, usesNativeSignatures } from './api-sections' @@ -85,6 +85,7 @@ function proseMarkdown(markdown: string, index: SymbolIndex, symbol: ApiSymbol, } if (!linkApi) return if (node.type === 'inlineCode') { + if (keywordArgument.test(node.value)) return if (!index.linkText(markdown.slice(start, end), symbol).some((span) => span.kind === 'ref')) return const link = index.resolve(node.value, 'any', symbol) if (link) edits.push({ start, end, text: `[${inlineCode(node.value)}](${link.href})` }) diff --git a/site/test/inline-reference-appearance.test.ts b/site/test/inline-reference-appearance.test.ts index a6f59cfea..2ba583ead 100644 --- a/site/test/inline-reference-appearance.test.ts +++ b/site/test/inline-reference-appearance.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { API_MODELS } from '../src/lib/api-models' import { productApiIndex } from '../src/lib/product-api' import { linkTmuxDoc } from '../src/lib/tmux-links' +import { symbolMarkdown } from '../src/lib/symbol-markdown' import { createLinkPreviewPolicy } from '../src/lib/link-preview-paint' const preview = readFileSync(new URL('../src/components/LinkPreview.astro', import.meta.url), 'utf8') @@ -131,6 +132,24 @@ it('renders the WhereOf generic as one code expression with independent native l } }) +it('keeps a keyword argument plain while its bare name still links', async () => { + const model = API_MODELS.ruby + const index = productApiIndex(model, 'latest') + const container = await experimental_AstroContainer.create() + const text = '`cancel:` `clients: true` `where:` `cancel`' + win.document.body.innerHTML = await container.renderToString(await docComponent(), { props: { text, index } }) + const codes = [...win.document.querySelectorAll('code.api-literal')] + expect(codes.map((code) => code.textContent)).toEqual(['cancel:', 'clients: true', 'where:', 'cancel']) + expect(codes.slice(0, 3).map((code) => code.querySelectorAll('a').length)).toEqual([0, 0, 0]) + expect(win.document.querySelectorAll('a[href*="cancellation-cancel"]')).toHaveLength(1) + // Markdown links code literals for TypeScript; the same rule keeps an option key plain there. + const ts = API_MODELS.ts + const summary = '`socketPath:` `socketPath`' + const server = { ...ts.symbols.find((entry) => entry.id === 'server.Server')!, doc: { summary } } + const markdown = symbolMarkdown({ model: ts, symbol: server, index: productApiIndex(ts, 'latest') }) + expect(markdown).toContain('`socketPath:` [`socketPath`](') +}) + it('leaves unrelated hashes, source comments and URL fragments unchanged', async () => { const model = API_MODELS.scala const index = productApiIndex(model, 'latest')