Skip to content
Closed
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
4 changes: 3 additions & 1 deletion site/src/lib/api-labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ const KIND_MARKS: Record<SymbolKind, string> = {
}

const PROTOCOL_MARKS: Record<string, string> = { tool: 't', prompt: 'P', resource: 'r', 'resource-template': 'R' }
export const kindMark = (kind: string) => KIND_MARKS[kind as SymbolKind] ?? PROTOCOL_MARKS[kind]
// tmux names borrow the sigils people already type: a shell prompt for commands, `#` for formats.
const TMUX_MARKS: Record<string, string> = { command: '$', option: 'o', hook: 'h', format: '#' }
export const kindMark = (kind: string) => KIND_MARKS[kind as SymbolKind] ?? PROTOCOL_MARKS[kind] ?? TMUX_MARKS[kind]

const namespaceSegments = new Intl.Segmenter('en', { granularity: 'grapheme' })
const FULL_NAMESPACE_LENGTH = 48
Expand Down
3 changes: 3 additions & 0 deletions site/src/lib/link-preview-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ function validPreview(value: unknown): value is LinkPreview {
const data = value as Partial<LinkPreview>
return data.schema === 1 && typeof data.title === 'string' && typeof data.description === 'string'
&& typeof data.href === 'string' && (!data.detail || typeof data.detail === 'string')
&& (!data.actionLabel || typeof data.actionLabel === 'string')
&& (!data.signature || typeof data.signature === 'string')
}

Expand Down Expand Up @@ -91,6 +92,7 @@ export function initLinkPreviews() {
let fullTitle = '', shortTitle = ''
const title = panel.querySelector<HTMLElement>('[data-preview-title]')!
const expandTitle = panel.querySelector<HTMLButtonElement>('[data-preview-expand]')!
const defaultAction = panel.querySelector('[data-preview-open]')!.textContent
const renderTitle = (expanded: boolean) => {
title.replaceChildren(...wordBreak(expanded ? fullTitle : shortTitle).flatMap((part, index) =>
index ? [document.createElement('wbr'), document.createTextNode(part)] : [document.createTextNode(part)]))
Expand Down Expand Up @@ -162,6 +164,7 @@ export function initLinkPreviews() {
renderPreviewSignature(panel.querySelector<HTMLElement>('[data-preview-signature]')!, preview)
const open = panel.querySelector<HTMLAnchorElement>('[data-preview-open]')!
open.href = destination.href
open.textContent = preview.actionLabel ?? defaultAction
panel.setAttribute('aria-label', `${preview.title} preview`)
link.setAttribute('aria-details', panel.id)
panel.showPopover({ source: link }); shown = true; place()
Expand Down
8 changes: 5 additions & 3 deletions site/src/lib/link-preview-paint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ export function createLinkPreviewPolicy() {
const reference = /\/reference\/[^/]+\/$/.test(url.pathname)
const protocolObject = /\/mcp\/(?:tools\/[^/]+|prompts\/[^/]+|resources\/(?:templates\/)?[^/]+)\/$/.test(url.pathname)
&& !url.pathname.endsWith('/mcp/resources/templates/')
if (url.origin !== origin || url.search || (!reference && !protocolObject)) return undefined
// Command pages, and the full manual, whose fragments name options, hooks and format variables.
const manual = /\/tmux\/[^/]+\/manual\/(?:full|[a-z]+(?:-[a-z]+)*)\/$/.test(url.pathname)
if (url.origin !== origin || url.search || (!reference && !protocolObject && !manual)) return undefined
return { url, endpoint: `${url.pathname.slice(0, -1)}.preview.json`, glossary: url.pathname.endsWith('/reference/glossary/') }
}
function candidate(eventTarget: EventTarget | null, base: string): HTMLAnchorElement | undefined {
const link = eventTarget instanceof Element ? eventTarget.closest<HTMLAnchorElement>('a[href]') : null
if (!link || link.matches('[data-preview-skip], [href^="#"], .api-heading-link, .api-index-card__link, .headerlink')
|| link.closest('nav, footer, [data-doc-picker], .api-index-card, .reference-link-preview')) return undefined
if (!link || link.matches('[data-preview-skip], [href^="#"], .api-heading-link, .api-index-card__link, .headerlink, .permalink')
|| link.closest('nav, footer, [data-doc-picker], .api-index-card, .cli-command-list, .reference-link-preview')) return undefined
try {
const url = new URL(link.getAttribute('href')!, base)
return target(url.href, new URL(base).origin) ? link : undefined
Expand Down
36 changes: 36 additions & 0 deletions site/src/lib/link-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { docSummaryText, qualifiedNameOf, sourceUrl, type ApiModel, type ApiSymb
import { productApiHref } from './product-api'
import { apiGlossary } from './api-glossary'
import { codeLang, highlightTokens, type CodeHighlight } from './highlight'
import { tmuxEntityHref } from './tmux-links'
import { tmuxManual, tmuxManualEntities, tmuxManualUrl, type TmuxManualEntity } from './tmux-manual-data'

/** Small, inert metadata shared by reference links and their preview cards. */
export interface LinkPreview {
Expand All @@ -14,6 +16,8 @@ export interface LinkPreview {
signatureLanguage?: string
signatureHighlight?: CodeHighlight
href: string
/** Replaces the card's default "Open reference" link text. */
actionLabel?: string
source?: string
terms?: Record<string, LinkPreview>
}
Expand Down Expand Up @@ -46,3 +50,35 @@ export function glossaryPreview(href: string): LinkPreview {
} satisfies LinkPreview]))),
}
}

const MANUAL_ACTION = 'Open manual'

function tmuxEntityPreview(entity: TmuxManualEntity, version: string): LinkPreview {
return {
schema: 1, title: entity.name, kind: entity.kind, description: entity.summary || entity.detail, detail: entity.detail,
signature: entity.usage, signatureLanguage: entity.usage ? 'tmux-usage' : undefined,
href: tmuxEntityHref(entity.kind, entity.name, version)!, actionLabel: MANUAL_ACTION,
}
}

/**
* Cards for the CLI manual: a command page previews its summary and syntax,
* and the full manual previews each option, hook and format variable by anchor.
*/
export function tmuxManualPreview(version: string, slug: string): LinkPreview {
const reference = tmuxManual(version)
if (slug === 'full') {
return {
schema: 1, title: `tmux ${reference.version} manual`, kind: 'manual', href: tmuxManualUrl(version, 'full'),
description: `Command syntax and behavior for tmux ${reference.version}.`, actionLabel: MANUAL_ACTION,
terms: Object.fromEntries(tmuxManualEntities(version).map((entity) => [entity.anchor, tmuxEntityPreview(entity, version)])),
}
}
const command = reference.commands.find((entry) => entry.name === slug)
if (!command) throw new Error(`Unknown tmux command: ${slug}`)
return {
schema: 1, title: command.name, kind: 'command', description: command.summary, href: tmuxEntityHref('command', command.name, version)!,
signature: `tmux ${command.name}${command.usage ? ` ${command.usage}` : ''}`, signatureLanguage: 'tmux-usage',
detail: command.alias ? `Alias: ${command.alias}.` : undefined, actionLabel: MANUAL_ACTION,
}
}
10 changes: 8 additions & 2 deletions site/src/lib/prose-markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { resolvePortContent } from './workspace-shared-slots'
import { createProseLinker, PORT_BY_LABEL } from './prose-resolver'
import { PORT_BY_SLUG } from './ports'
import { rewriteMarkdownLinks } from '../plugins/remark-port-code.mjs'
import { tmuxMentionMarkdown } from './tmux-links'
import { tmuxCommandMentions, tmuxMentionMarkdown } from './tmux-links'
import { isTmuxProse, type DocsPage } from './docs-paths'
import { documenterHeading } from '../plugins/remark-documenter-headings'

Expand All @@ -25,6 +25,7 @@ export function linkProseMarkdown(raw: string, port?: string, product?: ApiProdu
&& (!port || PORT_BY_SLUG[port]?.referenceKind !== 'guide')
const spans = new Set<number>()
const edits: { start: number; end: number; text: string }[] = []
const unclaimed: typeof edits = []
const link = createProseLinker(product)
let htmlDepth = 0
visit(fromMarkdown(body), (node) => {
Expand Down Expand Up @@ -69,7 +70,11 @@ export function linkProseMarkdown(raw: string, port?: string, product?: ApiProdu
const end = node.position.end.offset!
const native = tmuxMentionMarkdown(node.value, node.type === 'inlineCode', project.resolved)
if (native && (node.type === 'inlineCode' || body.slice(start, end) === node.value)) {
edits.push({ start, end, text: native })
// A command claims its span; a library symbol outranks an option, hook or format variable.
if (linkApi && node.type === 'inlineCode' && !tmuxCommandMentions(node.value, { code: true }).length) {
unclaimed.push({ start, end, text: native })
spans.add(start)
} else edits.push({ start, end, text: native })
return SKIP
}
}
Expand All @@ -85,6 +90,7 @@ export function linkProseMarkdown(raw: string, port?: string, product?: ApiProdu
const href = (project.resolved?.(decision.href) ?? decision.href).replaceAll('<', '%3C').replaceAll('>', '%3E')
edits.push({ start: mention.start, end: mention.end, text: `[${body.slice(mention.start, mention.end)}](<${href}>)` })
}
for (const edit of unclaimed) if (!edits.some((claimed) => claimed.start === edit.start)) edits.push(edit)
let out = body
for (const edit of edits.sort((a, b) => b.start - a.start)) out = `${out.slice(0, edit.start)}${edit.text}${out.slice(edit.end)}`
return out
Expand Down
13 changes: 10 additions & 3 deletions site/src/lib/symbol-markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,11 @@ function inlineCode(code: string): string {
}

function proseMarkdown(markdown: string, index: SymbolIndex, symbol: ApiSymbol, linkApi: boolean): string {
if (!linkApi && !/\btmux\s/.test(markdown) && !tmuxCommandMentions(markdown).length) return markdown
if (!linkApi && !/\btmux\s|[-_]/.test(markdown)) return markdown
// Use the HTML resolver for prose exports too. Never rewrite runnable code
// blocks or the contents of an author-supplied link.
const edits: { start: number; end: number; text: string }[] = []
const unclaimed: typeof edits = []
let htmlDepth = 0
const visit = (node: ReturnType<typeof fromMarkdown> | ReturnType<typeof fromMarkdown>['children'][number]) => {
if (node.type === 'link' || node.type === 'linkReference' || node.type === 'code') return
Expand All @@ -80,8 +81,13 @@ function proseMarkdown(markdown: string, index: SymbolIndex, symbol: ApiSymbol,
const end = node.position!.end.offset!
const tmux = tmuxMentionMarkdown(node.value, node.type === 'inlineCode')
if (tmux && (node.type === 'inlineCode' || markdown.slice(start, end) === node.value)) {
edits.push({ start, end, text: tmux })
return
// A command claims its span; a library symbol outranks an option, hook or format variable.
if (linkApi && node.type === 'inlineCode' && !tmuxCommandMentions(node.value, { code: true }).length) {
unclaimed.push({ start, end, text: tmux })
} else {
edits.push({ start, end, text: tmux })
return
}
}
if (!linkApi) return
if (node.type === 'inlineCode') {
Expand Down Expand Up @@ -111,6 +117,7 @@ function proseMarkdown(markdown: string, index: SymbolIndex, symbol: ApiSymbol,
} else if ('children' in node) for (const child of node.children) visit(child as Parameters<typeof visit>[0])
}
visit(fromMarkdown(markdown))
for (const edit of unclaimed) if (!edits.some((claimed) => claimed.start === edit.start)) edits.push(edit)
for (const edit of edits.sort((a, b) => b.start - a.start)) markdown = markdown.slice(0, edit.start) + edit.text + markdown.slice(edit.end)
return markdown
}
Expand Down
107 changes: 98 additions & 9 deletions site/src/lib/tmux-links.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import type { DocSpan, LinkTarget } from '@libtmux/api-model'
import { tmuxManual, tmuxManualUrl } from './tmux-manual-data'
import { tmuxManual, tmuxManualEntityHref, tmuxManualEntities, type TmuxEntityKind } from './tmux-manual-data'

export type { TmuxEntityKind } from './tmux-manual-data'

/**
* The one place a tmux name becomes a destination.
*
* Every command, option, hook and format-variable link resolves here, and an
* unknown name has none. Swap the body to move them all to another reference.
*/
export function tmuxEntityHref(kind: TmuxEntityKind, name: string, version = 'latest'): string | undefined {
return tmuxManualEntityHref(kind, name, version)
}

type Command = ReturnType<typeof tmuxManual>['commands'][number]
const commandNames = new WeakMap<ReturnType<typeof tmuxManual>, Map<string, Command>>()
Expand All @@ -12,6 +24,14 @@ export interface TmuxMention {
href: string
}

const CODE_WORDS = /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|#[^\n]*|[A-Za-z0-9_/-]+|[^\s]/g
const PROSE_WORDS = /[A-Za-z0-9_/-]+|[^\s]/g
const tokenize = (text: string, code: boolean) => [...text.matchAll(code ? CODE_WORDS : PROSE_WORDS)]
.map((match) => ({ text: match[0], start: match.index!, end: match.index! + match[0].length }))
/** A word inside a longer path, flag, option value or sentence continuation is not a name. */
const standalone = (text: string, token: { start: number; end: number }) =>
!/[\w./:$-]/.test(text[token.start - 1] ?? '') && !/^[\w/-]|^\.[A-Za-z]/.test(text.slice(token.end))

/** Locate documented command names; aliases need an explicit code invocation. */
export function tmuxCommandMentions(text: string, { code = false, version = 'latest' } = {}): TmuxMention[] {
const manual = tmuxManual(version)
Expand All @@ -23,17 +43,15 @@ export function tmuxCommandMentions(text: string, { code = false, version = 'lat
] as [string, Command][]))
commandNames.set(manual, byName)
}
const words = code ? /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|#[^\n]*|[A-Za-z0-9_/-]+|[^\s]/g : /[A-Za-z0-9_/-]+|[^\s]/g
const tokens = [...text.matchAll(words)]
.map((match) => ({ text: match[0], start: match.index!, end: match.index! + match[0].length }))
const tokens = tokenize(text, code)
const found = new Map<number, TmuxMention>()
const add = (position: number, aliases = false) => {
const token = tokens[position]
if (!token) return
const command = byName.get(token.text)
if (!command || (!aliases && command.name !== token.text)) return
if (/[\w./:$-]/.test(text[token.start - 1] ?? '') || /^[\w/-]|^\.[A-Za-z]/.test(text.slice(token.end))) return
found.set(token.start, { ...token, command: command.name, href: tmuxManualUrl(version, command.name) })
if (!standalone(text, token)) return
found.set(token.start, { ...token, command: command.name, href: tmuxEntityHref('command', command.name, version)! })
}

// Global option values are not commands, even when named run-shell.
Expand Down Expand Up @@ -67,15 +85,86 @@ export function tmuxCommandMentions(text: string, { code = false, version = 'lat
return [...found.values()].sort((a, b) => a.start - b.start)
}

/** A documented tmux name found in text, whatever its kind. */
export interface TmuxReference {
start: number
end: number
text: string
kind: TmuxEntityKind
name: string
href: string
}

// Plain words stay plain: only a hyphenated option or hook and an underscored
// format variable are unlikely enough in running code to claim without context.
const namedEntities = new WeakMap<ReturnType<typeof tmuxManual>, Map<string, Exclude<TmuxEntityKind, 'command'>>>()
function entityKinds(version: string) {
const manual = tmuxManual(version)
let kinds = namedEntities.get(manual)
if (!kinds) {
kinds = new Map(tmuxManualEntities(version)
.filter((entity) => entity.name.includes(entity.kind === 'format' ? '_' : '-'))
.map((entity) => [entity.name, entity.kind]))
namedEntities.set(manual, kinds)
}
return kinds
}

/** Whether an offset sits inside the braces of a `#{...}` format expression. */
function insideFormat(text: string, offset: number): boolean {
const groups: boolean[] = []
for (let index = 0; index < offset; index++) {
if (text[index] === '{') groups.push(text[index - 1] === '#')
else if (text[index] === '}') groups.pop()
}
return groups.includes(true)
}

/**
* A format variable is a mention when it is the whole span or part of a
* format expression, quoted or not. A name in a URI template or a call is
* something else.
*/
function formatVariable(text: string, start: number, end: number): boolean {
if (text.trim() === text.slice(start, end)) return true
return insideFormat(text, start) && /[{?!,:]/.test(text[start - 1] ?? '') && !/^[\w/-]|^\.[A-Za-z]/.test(text.slice(end))
}

const FORMAT_NAME = /[a-z][a-z0-9]*(?:_[a-z0-9]+)+/g

/**
* Locate every documented tmux name. Commands also match in prose; options,
* hooks and format variables only inside code, where a name is deliberate.
*/
export function tmuxMentions(text: string, { code = false, version = 'latest' } = {}): TmuxReference[] {
const found: TmuxReference[] = tmuxCommandMentions(text, { code, version })
.map(({ command, ...mention }) => ({ ...mention, kind: 'command', name: command }))
if (!code || !/[-_]/.test(text)) return found
const kinds = entityKinds(version)
const taken = new Set(found.map((mention) => mention.start))
const add = (kind: Exclude<TmuxEntityKind, 'command'>, name: string, start: number) => {
const href = tmuxEntityHref(kind, name, version)
if (href && !taken.has(start)) found.push({ start, end: start + name.length, text: name, kind, name, href })
}
for (const token of tokenize(text, true)) {
const kind = kinds.get(token.text)
if (kind && kind !== 'format' && standalone(text, token)) add(kind, token.text, token.start)
}
for (const match of text.matchAll(FORMAT_NAME)) {
if (kinds.get(match[0]) === 'format' && formatVariable(text, match.index!, match.index! + match[0].length)) add('format', match[0], match.index!)
}
return found.sort((a, b) => a.start - b.start)
}

type LinkedDocSpan = DocSpan & { link?: LinkTarget }

/** Add native command links after language API and authored-link resolution. */
/** Add native tmux links after language API and authored-link resolution. */
export function linkTmuxDoc(spans: LinkedDocSpan[], version = 'latest'): LinkedDocSpan[] {
return spans.flatMap((span): LinkedDocSpan[] => {
if (!['text', 'code', 'ref'].includes(span.kind) || span.link) return [span]
const text = 'text' in span ? span.text : span.label
const code = span.kind !== 'text'
const mentions = tmuxCommandMentions(text, { code, version })
const mentions = tmuxMentions(text, { code, version })
if (!mentions.length) return [span]
if (code && mentions.length === 1) return [{ kind: 'link', text, code: true, href: mentions[0].href }]
const linked: LinkedDocSpan[] = []
Expand All @@ -92,7 +181,7 @@ export function linkTmuxDoc(spans: LinkedDocSpan[], version = 'latest'): LinkedD

/** Link one Markdown prose node without touching executable fences. */
export function tmuxMentionMarkdown(text: string, code: boolean, project: (href: string) => string = (href) => href): string | undefined {
const mentions = tmuxCommandMentions(text, { code })
const mentions = tmuxMentions(text, { code })
if (!mentions.length) return undefined
const literal = (value: string) => {
if (!code) return value
Expand Down
Loading
Loading