diff --git a/packages/intent/src/commands/load.ts b/packages/intent/src/commands/load.ts index 4e6b92e..f76001d 100644 --- a/packages/intent/src/commands/load.ts +++ b/packages/intent/src/commands/load.ts @@ -1,9 +1,10 @@ import { existsSync, readFileSync } from 'node:fs' -import { isAbsolute, relative, resolve } from 'node:path' +import { dirname, isAbsolute, relative, resolve } from 'node:path' import { fail } from '../cli-error.js' import { scanOptionsFromGlobalFlags } from '../cli-support.js' import { resolveSkillUse } from '../resolver.js' import { parseSkillUse } from '../skill-use.js' +import { toPosixPath } from '../utils.js' import type { GlobalScanFlags } from '../cli-support.js' import type { ScanOptions, ScanResult } from '../types.js' @@ -27,6 +28,314 @@ function isPathInsidePackageRoot(path: string, packageRoot: string): boolean { ) } +function splitDestinationSuffix(destination: string): { + pathPart: string + suffix: string +} { + const hashIndex = destination.indexOf('#') + const queryIndex = destination.indexOf('?') + const suffixIndex = + hashIndex === -1 + ? queryIndex + : queryIndex === -1 + ? hashIndex + : Math.min(hashIndex, queryIndex) + + if (suffixIndex === -1) { + return { pathPart: destination, suffix: '' } + } + + return { + pathPart: destination.slice(0, suffixIndex), + suffix: destination.slice(suffixIndex), + } +} + +function isExternalOrAbsoluteDestination(destination: string): boolean { + return ( + destination === '' || + destination.startsWith('#') || + destination.startsWith('?') || + destination.startsWith('//') || + /^[A-Za-z][A-Za-z0-9+.-]*:/.test(destination) || + isAbsolute(destination) + ) +} + +interface MarkdownDestinationRewriteContext { + cwd: string + resolvedPackageRoot: string + skillDir: string +} + +function findClosingBracket(line: string, start: number): number { + let depth = 0 + + for (let index = start; index < line.length; index++) { + const char = line[index]! + if (char === '\\') { + index++ + continue + } + if (char === '[') { + depth++ + continue + } + if (char === ']') { + depth-- + if (depth === 0) return index + } + } + + return -1 +} + +function findClosingParen(line: string, start: number): number { + for (let index = start; index < line.length; index++) { + const char = line[index]! + if (char === '\\') { + index++ + continue + } + if (char === ')') return index + } + + return -1 +} + +function readBareDestination( + line: string, + start: number, +): { destinationEnd: number; endParen: number } | null { + let depth = 0 + + for (let index = start; index < line.length; index++) { + const char = line[index]! + if (char === '\\') { + index++ + continue + } + if (char === '(') { + depth++ + continue + } + if (char === ')') { + if (depth === 0) { + return { destinationEnd: index, endParen: index } + } + depth-- + continue + } + if (/\s/.test(char) && depth === 0) { + const endParen = findClosingParen(line, index) + if (endParen === -1) return null + return { destinationEnd: index, endParen } + } + } + + return null +} + +function readMarkdownDestination( + line: string, + start: number, +): { + destination: string + destinationStart: number + destinationEnd: number + endParen: number +} | null { + let cursor = start + while (cursor < line.length && /\s/.test(line[cursor]!)) cursor++ + + if (line[cursor] === '<') { + const destinationStart = cursor + 1 + const destinationEnd = line.indexOf('>', destinationStart) + if (destinationEnd === -1) return null + const endParen = findClosingParen(line, destinationEnd + 1) + if (endParen === -1) return null + return { + destination: line.slice(destinationStart, destinationEnd), + destinationStart, + destinationEnd, + endParen, + } + } + + const read = readBareDestination(line, cursor) + if (!read) return null + + return { + destination: line.slice(cursor, read.destinationEnd), + destinationStart: cursor, + destinationEnd: read.destinationEnd, + endParen: read.endParen, + } +} + +function getCodeFenceMarker(line: string): '`' | '~' | null { + const match = line.match(/^\s*(`{3,}|~{3,})/) + const marker = match?.[1]?.[0] + return marker === '`' || marker === '~' ? marker : null +} + +function rewriteMarkdownDestination({ + context, + destination, +}: { + context: MarkdownDestinationRewriteContext + destination: string +}): string { + if (isExternalOrAbsoluteDestination(destination)) return destination + + const { pathPart, suffix } = splitDestinationSuffix(destination) + if (isExternalOrAbsoluteDestination(pathPart)) return destination + + const resolvedDestinationPath = resolve(context.skillDir, pathPart) + const relativeToPackageRoot = relative( + context.resolvedPackageRoot, + resolvedDestinationPath, + ) + if ( + relativeToPackageRoot.startsWith('..') || + isAbsolute(relativeToPackageRoot) + ) { + return destination + } + + const relativeToCwd = relative(context.cwd, resolvedDestinationPath) + const rewrittenPath = + relativeToCwd && + !relativeToCwd.startsWith('..') && + !isAbsolute(relativeToCwd) + ? relativeToCwd + : resolvedDestinationPath + + return `${toPosixPath(rewrittenPath)}${suffix}` +} + +function rewriteMarkdownLineDestinations({ + context, + line, +}: { + context: MarkdownDestinationRewriteContext + line: string +}): string { + if (!line.includes('[')) return line + + let output = '' + let cursor = 0 + + while (cursor < line.length) { + const nextCodeStart = line.indexOf('`', cursor) + const nextLinkStart = line.indexOf('[', cursor) + + if (nextLinkStart === -1) { + output += line.slice(cursor) + break + } + + if (nextCodeStart !== -1 && nextCodeStart < nextLinkStart) { + output += line.slice(cursor, nextCodeStart) + cursor = nextCodeStart + const codeStart = cursor + while (cursor < line.length && line[cursor] === '`') cursor++ + const marker = line.slice(codeStart, cursor) + const codeEnd = line.indexOf(marker, cursor) + if (codeEnd === -1) { + output += line.slice(codeStart) + break + } + output += line.slice(codeStart, codeEnd + marker.length) + cursor = codeEnd + marker.length + continue + } + + const linkStart = + nextLinkStart > 0 && line[nextLinkStart - 1] === '!' + ? nextLinkStart - 1 + : nextLinkStart + output += line.slice(cursor, linkStart) + + const labelStart = nextLinkStart + const labelEnd = findClosingBracket(line, labelStart) + if (labelEnd === -1) { + output += line.slice(linkStart) + break + } + + if (line[labelEnd + 1] !== '(') { + output += line.slice(linkStart, nextLinkStart + 1) + cursor = nextLinkStart + 1 + continue + } + + const destination = readMarkdownDestination(line, labelEnd + 2) + if (!destination) { + output += line.slice(linkStart, nextLinkStart + 1) + cursor = nextLinkStart + 1 + continue + } + + const rewritten = rewriteMarkdownDestination({ + context, + destination: destination.destination, + }) + output += + line.slice(linkStart, destination.destinationStart) + + rewritten + + line.slice(destination.destinationEnd, destination.endParen + 1) + cursor = destination.endParen + 1 + } + + return output +} + +function rewriteLoadedSkillMarkdownDestinations({ + content, + packageRoot, + skillFilePath, +}: { + content: string + packageRoot: string + skillFilePath: string +}): string { + const context: MarkdownDestinationRewriteContext = { + cwd: process.cwd(), + resolvedPackageRoot: resolveFromCwd(packageRoot), + skillDir: dirname(skillFilePath), + } + let inFence: '`' | '~' | null = null + const parts = content.split(/(\r?\n)/) + let output = '' + + for (let index = 0; index < parts.length; index += 2) { + const line = parts[index] ?? '' + const newline = parts[index + 1] ?? '' + const marker = getCodeFenceMarker(line) + + if (inFence) { + output += line + newline + if (marker === inFence) inFence = null + continue + } + + if (marker) { + inFence = marker + output += line + newline + continue + } + + output += + rewriteMarkdownLineDestinations({ + context, + line, + }) + newline + } + + return output +} + export async function runLoadCommand( use: string | undefined, options: LoadCommandOptions, @@ -64,7 +373,11 @@ export async function runLoadCommand( return } - const content = readFileSync(resolvedPath, 'utf8') + const content = rewriteLoadedSkillMarkdownDestinations({ + content: readFileSync(resolvedPath, 'utf8'), + packageRoot: resolved.packageRoot, + skillFilePath: resolvedPath, + }) if (options.json) { console.log( diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 74e0af4..425859c 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -849,6 +849,65 @@ describe('cli commands', () => { expect(output).toContain('Skill content here.') }) + it('rewrites relative markdown destinations when loading a skill', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-load-links-')) + tempDirs.push(root) + const pkgDir = join(root, 'node_modules', '@tanstack', 'query') + const skillDir = join(pkgDir, 'skills', 'fetching') + writeJson(join(pkgDir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + mkdirSync(skillDir, { recursive: true }) + writeFileSync( + join(skillDir, 'SKILL.md'), + [ + '---', + 'name: fetching', + 'description: Query data fetching patterns', + '---', + '', + '- [Reference](references/topic.md)', + '- ![Diagram](assets/diagram.png)', + '- [Parent](../shared.md#setup)', + '- [External](https://example.com/reference.md)', + '- [Mail](mailto:test@example.com)', + '- [Anchor](#setup)', + '- [Absolute](/tmp/reference.md)', + '- [Escapes](../../../outside.md)', + '- `inline [Code](references/code.md)`', + '```md', + '[Fenced](references/fenced.md)', + '```', + '', + ].join('\n'), + ) + + process.chdir(root) + + const exitCode = await main(['load', '@tanstack/query#fetching']) + const output = stdoutWriteSpy.mock.calls.flat().join('') + + expect(exitCode).toBe(0) + expect(output).toContain( + '[Reference](node_modules/@tanstack/query/skills/fetching/references/topic.md)', + ) + expect(output).toContain( + '![Diagram](node_modules/@tanstack/query/skills/fetching/assets/diagram.png)', + ) + expect(output).toContain( + '[Parent](node_modules/@tanstack/query/skills/shared.md#setup)', + ) + expect(output).toContain('[External](https://example.com/reference.md)') + expect(output).toContain('[Mail](mailto:test@example.com)') + expect(output).toContain('[Anchor](#setup)') + expect(output).toContain('[Absolute](/tmp/reference.md)') + expect(output).toContain('[Escapes](../../../outside.md)') + expect(output).toContain('`inline [Code](references/code.md)`') + expect(output).toContain('[Fenced](references/fenced.md)') + }) + it('loads a local skill use to a path with --path', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-load-path-')) tempDirs.push(root) @@ -906,6 +965,42 @@ describe('cli commands', () => { }) }) + it('rewrites relative markdown destinations in json load content', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-load-json-links-')) + tempDirs.push(root) + const pkgDir = join(root, 'node_modules', '@tanstack', 'query') + const skillDir = join(pkgDir, 'skills', 'fetching') + writeJson(join(pkgDir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + mkdirSync(skillDir, { recursive: true }) + writeFileSync( + join(skillDir, 'SKILL.md'), + [ + '---', + 'name: fetching', + 'description: Query data fetching patterns', + '---', + '', + '[Reference](references/topic.md)', + '', + ].join('\n'), + ) + + process.chdir(root) + + const exitCode = await main(['load', '@tanstack/query#fetching', '--json']) + const output = logSpy.mock.calls.at(-1)?.[0] + const parsed = JSON.parse(String(output)) as { content: string } + + expect(exitCode).toBe(0) + expect(parsed.content).toContain( + '[Reference](node_modules/@tanstack/query/skills/fetching/references/topic.md)', + ) + }) + it('loads global fallback path when requested', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-load-global-')) const globalRoot = mkdtempSync(