-
Notifications
You must be signed in to change notification settings - Fork 119
feat(curl,task): highlight + pretty-print responses #1432
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ff75b54
a07653c
e6a59fe
78bfd65
e37b5c9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,5 @@ | ||
| import type { HighlightLanguage } from '../utils/highlight' | ||
|
|
||
| import { Buffer } from 'node:buffer' | ||
| import { readFile } from 'node:fs/promises' | ||
| import process from 'node:process' | ||
|
|
@@ -7,15 +9,35 @@ import { note } from '@clack/prompts' | |
| import { defineCommand } from 'citty' | ||
|
|
||
| import { findDevServer, noDevServerMessage } from '../utils/dev-server' | ||
| import { highlightJson } from '../utils/json-highlight' | ||
| import { formatHtml } from '../utils/format-html' | ||
| import { highlight } from '../utils/highlight' | ||
| import { logger } from '../utils/logger' | ||
| import { logNetworkError } from '../utils/network' | ||
| import { resolveRootDir } from '../utils/paths' | ||
| import { rootDirArgs } from './_shared' | ||
|
|
||
| const HAS_SCHEME_RE = /^[a-z][a-z\d+.-]*:\/\//i | ||
| const JSON_CONTENT_TYPE_RE = /^application\/(?:[\w.+-]+\+)?json\b/i | ||
| const TEXT_CONTENT_TYPE_RE = /^(?:text\/|application\/(?:[\w.+-]+\+)?(?:json|xml|yaml)\b|application\/(?:javascript|ecmascript|x-www-form-urlencoded|x-ndjson)\b)/i | ||
| /** Markup reindented as HTML: the HTML rules cover XML documents too. */ | ||
| const MARKUP_CONTENT_TYPE_RE = /^(?:text\/(?:html|xml)|(?:application|image)\/(?:[\w.+-]+\+)?xml)\b/i | ||
|
|
||
| /** Newline-delimited JSON keeps one record per line, so each line is highlighted on its own. */ | ||
| const NDJSON_CONTENT_TYPE_RE = /^application\/(?:(?:x-)?ndjson|jsonl)\b/i | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
rg -n -C 5 'NDJSON_CONTENT_TYPE_RE|CONTENT_TYPE_LANGUAGES|json-seq|resolveRenderer' \
packages/nuxt-cli/src/commands/curl.ts \
packages/nuxt-cli/test/unit/commands/curl.spec.tsRepository: nuxt/cli Length of output: 4093 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== curl.ts relevant sections =="
sed -n '1,120p' packages/nuxt-cli/src/commands/curl.ts
sed -n '310,345p' packages/nuxt-cli/src/commands/curl.ts
echo "== json-seq occurrences =="
rg -n 'json-seq|NDJSON|JSON_CONTENT_TYPE_RE|CONTENT_TYPE_LANGUAGES|highlight\(' packages/nuxt-cli/src packages/nuxt-cli/test || trueRepository: nuxt/cli Length of output: 8921 Restore
🤖 Prompt for AI Agents |
||
|
|
||
| /** Languages worth highlighting a response body in, keyed by content type. */ | ||
| const CONTENT_TYPE_LANGUAGES: [RegExp, HighlightLanguage][] = [ | ||
| [/^text\/(?:x-)?markdown\b/i, 'md'], | ||
| [/^(?:text|application)\/(?:x-)?(?:[\w.+-]+\+)?ya?ml\b/i, 'yaml'], | ||
| [/^text\/css\b/i, 'css'], | ||
| [/^(?:text|application)\/(?:x-)?(?:java|ecma)script\b/i, 'js'], | ||
| [/^(?:text|application)\/(?:x-)?typescript\b/i, 'ts'], | ||
| [/^(?:text\/x-(?:diff|patch)|application\/x-patch)\b/i, 'diff'], | ||
| [/^(?:text\/x-(?:sh|shellscript)|application\/x-sh(?:ellscript)?)\b/i, 'bash'], | ||
| [/^(?:text|application)\/x-python\b/i, 'py'], | ||
| [/^message\/http\b/i, 'http'], | ||
| ] | ||
| /** Textual types with no highlighter of their own. */ | ||
| const TEXT_CONTENT_TYPE_RE = /^(?:text\/|application\/x-www-form-urlencoded\b)/i | ||
|
|
||
| const BINARY_SNIFF_BYTES = 4096 | ||
|
|
||
|
|
@@ -68,6 +90,10 @@ export default defineCommand({ | |
| alias: 'v', | ||
| description: 'Print request and response headers to stderr', | ||
| }, | ||
| pretty: { | ||
| type: 'boolean', | ||
| description: 'Reindent and syntax-highlight output (default: on for a terminal, off when piped). Use `--pretty`/`--no-pretty` to force reindenting.', | ||
| }, | ||
| }, | ||
| async run(ctx) { | ||
| const cwd = resolveRootDir(ctx.args) | ||
|
|
@@ -123,15 +149,17 @@ export default defineCommand({ | |
| process.exit(1) | ||
| } | ||
|
|
||
| const pretty = ctx.args.pretty ?? Boolean(process.stdout.isTTY) | ||
|
|
||
| if (ctx.args.verbose) { | ||
| process.stderr.write(formatResponseHead(response, '< ')) | ||
| process.stderr.write(formatResponseHead(response, '< ', process.stderr, ctx.args.pretty ?? Boolean(process.stderr.isTTY))) | ||
| } | ||
|
|
||
| if (ctx.args.include || ctx.args.head) { | ||
| process.stdout.write(formatResponseHead(response, '')) | ||
| process.stdout.write(formatResponseHead(response, '', process.stdout, pretty)) | ||
| } | ||
|
|
||
| await writeResponseBody(response) | ||
| await writeResponseBody(response, pretty) | ||
|
|
||
| if (!response.ok) { | ||
| process.exit(HTTP_ERROR_EXIT_CODE) | ||
|
|
@@ -206,59 +234,111 @@ async function readRequestBody(data: string | undefined): Promise<string | undef | |
| return data | ||
| } | ||
|
|
||
| function formatResponseHead(response: Response, prefix: string): string { | ||
| let head = `${prefix}HTTP/1.1 ${response.status} ${response.statusText}\n` | ||
| function statusStyle(status: number): 'green' | 'cyan' | 'yellow' | 'red' { | ||
| if (status >= 500) { | ||
| return 'red' | ||
| } | ||
| if (status >= 400) { | ||
| return 'yellow' | ||
| } | ||
| if (status >= 300) { | ||
| return 'cyan' | ||
| } | ||
| return 'green' | ||
| } | ||
|
|
||
| function formatResponseHead(response: Response, prefix: string, stream: NodeJS.WriteStream, pretty: boolean): string { | ||
| const paint = (style: Parameters<typeof styleText>[0], text: string): string => | ||
| pretty ? styleText(style, text, { stream }) : text | ||
| const status = paint(statusStyle(response.status), `${response.status} ${response.statusText}`.trimEnd()) | ||
| let head = `${prefix}${paint('dim', 'HTTP/1.1')} ${status}\n` | ||
| for (const [name, value] of response.headers) { | ||
| head += `${prefix}${name}: ${value}\n` | ||
| head += `${prefix}${paint('blue', name)}: ${value}\n` | ||
| } | ||
| return `${head}${prefix.trimEnd()}\n` | ||
| } | ||
|
|
||
| async function writeResponseBody(response: Response): Promise<void> { | ||
| async function writeResponseBody(response: Response, pretty: boolean): Promise<void> { | ||
| const contentType = response.headers.get('content-type') || '' | ||
| const buffer = Buffer.from(await response.arrayBuffer()) | ||
| if (!buffer.length) { | ||
| return | ||
| } | ||
|
|
||
| if (!process.stdout.isTTY) { | ||
| if (!pretty) { | ||
| process.stdout.write(buffer) | ||
| return | ||
| } | ||
|
|
||
| if (isBinary(buffer, contentType)) { | ||
| const renderer = resolveRenderer(contentType) | ||
|
|
||
| if (isBinary(buffer, contentType, renderer)) { | ||
| note('Binary data not shown in terminal. Redirect the output to a file to save it.', 'Response body') | ||
| return | ||
| } | ||
|
|
||
| const text = buffer.toString('utf-8') | ||
| process.stdout.write(JSON_CONTENT_TYPE_RE.test(contentType) ? formatJson(text) : text) | ||
| if (!text.endsWith('\n')) { | ||
| const text = decodeBody(buffer, contentType) | ||
| const body = renderer?.(text) ?? text | ||
| process.stdout.write(body) | ||
| if (!body.endsWith('\n')) { | ||
| process.stdout.write('\n') | ||
| } | ||
| } | ||
|
|
||
| /** Decode with the charset the response declares, falling back to UTF-8 when it is absent or unknown. */ | ||
| function decodeBody(buffer: Buffer, contentType: string): string { | ||
| const charset = /;\s*charset=["']?([\w-]+)/i.exec(contentType)?.[1] | ||
| if (charset) { | ||
| try { | ||
| return new TextDecoder(charset).decode(buffer) | ||
| } | ||
| catch { | ||
| // fall through to UTF-8 | ||
| } | ||
| } | ||
| return buffer.toString('utf-8') | ||
| } | ||
|
|
||
| /** | ||
| * A textual content type is trusted outright; anything else is sniffed for a | ||
| * NUL byte, which no valid UTF-8 text response contains. | ||
| * A content type we can render, or one matching `TEXT_CONTENT_TYPE_RE`, is | ||
| * trusted outright; anything else is sniffed for a NUL byte, which no valid | ||
| * UTF-8 text response contains. | ||
| */ | ||
| function isBinary(buffer: Buffer, contentType: string): boolean { | ||
| if (TEXT_CONTENT_TYPE_RE.test(contentType)) { | ||
| function isBinary(buffer: Buffer, contentType: string, renderer: ((text: string) => string) | undefined): boolean { | ||
| if (renderer || TEXT_CONTENT_TYPE_RE.test(contentType)) { | ||
| return false | ||
| } | ||
| return buffer.subarray(0, BINARY_SNIFF_BYTES).includes(0) | ||
| } | ||
|
|
||
| function formatJson(text: string): string { | ||
| function renderJson(text: string): string { | ||
| let json: string | ||
| try { | ||
| json = JSON.stringify(JSON.parse(text), null, 2) | ||
| } | ||
| catch { | ||
| return text | ||
| } | ||
| return highlight(json, 'json') | ||
| } | ||
|
|
||
| return highlightJson(json) | ||
| /** | ||
| * How to render a response body of `contentType`, or `undefined` to print it | ||
| * verbatim. Recognising a renderer is also what marks a type as text, so | ||
| * `isBinary` never sniffs something we already know how to highlight. | ||
| */ | ||
| function resolveRenderer(contentType: string): ((text: string) => string) | undefined { | ||
| if (JSON_CONTENT_TYPE_RE.test(contentType)) { | ||
| return renderJson | ||
| } | ||
| if (NDJSON_CONTENT_TYPE_RE.test(contentType)) { | ||
| return text => text.replace(/[^\n]+/g, line => highlight(line, 'json')) | ||
| } | ||
| if (MARKUP_CONTENT_TYPE_RE.test(contentType)) { | ||
| return text => highlight(formatHtml(text), 'html') | ||
| } | ||
| const language = CONTENT_TYPE_LANGUAGES.find(([pattern]) => pattern.test(contentType))?.[1] | ||
| return language ? text => highlight(text, language) : undefined | ||
| } | ||
|
|
||
| function isJson(value: string): boolean { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| /** Phrasing content, kept on the line of its surrounding text. */ | ||
| const INLINE = new Set(['a', 'abbr', 'b', 'bdi', 'bdo', 'br', 'cite', 'code', 'data', 'dfn', 'em', 'i', 'img', 'kbd', 'mark', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'time', 'u', 'var', 'wbr']) | ||
| const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']) | ||
| /** Elements whose content is text rather than markup, and so is never reindented. */ | ||
| const RAW = new Set(['pre', 'textarea', 'script', 'style']) | ||
|
|
||
| const NODE_RE = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<[!/]?[a-z](?:[^>"']|"[^"]*"|'[^']*')*>/gi | ||
| const TAG_NAME_RE = /^<\/?\s*([a-z][\w:-]*)/i | ||
|
|
||
| /** | ||
| * Reindent an HTML document so a minified response is readable. | ||
| * | ||
| * Only whitespace between block-level boundaries is rewritten. Inline elements | ||
| * keep their surrounding text, and `pre`, `textarea`, `script` and `style` | ||
| * bodies are copied verbatim, so what a browser renders does not change. | ||
| */ | ||
| export function formatHtml(html: string, indent = ' '): string { | ||
| const lines: string[] = [] | ||
| const stack: { name: string, line: number }[] = [] | ||
| let line = '' | ||
| let position = 0 | ||
|
|
||
| const pad = (): string => indent.repeat(stack.length) | ||
| const push = (text: string): void => { | ||
| lines.push(pad() + text.replace(/\n[^\S\n]*/g, `\n${pad()}`)) | ||
| } | ||
| const flush = (): void => { | ||
| if (line.trim()) { | ||
| push(line.trim()) | ||
| } | ||
| line = '' | ||
| } | ||
|
|
||
| NODE_RE.lastIndex = 0 | ||
| let match: RegExpExecArray | null | ||
| // eslint-disable-next-line no-cond-assign | ||
| while ((match = NODE_RE.exec(html))) { | ||
| line += html.slice(position, match.index) | ||
| position = NODE_RE.lastIndex | ||
|
|
||
| const node = match[0] | ||
| const name = TAG_NAME_RE.exec(node)?.[1]?.toLowerCase() | ||
| if (!name || INLINE.has(name)) { | ||
| line += node | ||
| continue | ||
| } | ||
|
|
||
| const closing = node[1] === '/' | ||
| const selfClosing = node.endsWith('/>') || VOID.has(name) | ||
|
|
||
| if (RAW.has(name) && !closing && !selfClosing) { | ||
| const rest = html.slice(position) | ||
| const end = new RegExp(`</${name}\\s*>`, 'i').exec(rest) | ||
| const body = end ? rest.slice(0, end.index + end[0].length) : rest | ||
| flush() | ||
| lines.push(pad() + node + body) | ||
| position += body.length | ||
| NODE_RE.lastIndex = position | ||
| continue | ||
| } | ||
|
|
||
| if (closing) { | ||
| const index = stack.findLastIndex(entry => entry.name === name) | ||
| // A stray closing tag is printed where it stands, without dedenting. | ||
| if (index === -1) { | ||
| flush() | ||
| push(node) | ||
| continue | ||
| } | ||
| const open = stack[index]! | ||
| // A block with only inline children stays on the line it opened on. | ||
| if (index === stack.length - 1 && open.line === lines.length && line.trim() && !line.includes('\n')) { | ||
| lines[open.line - 1] += `${line.trim()}${node}` | ||
| line = '' | ||
| stack.length = index | ||
| continue | ||
| } | ||
| flush() | ||
| stack.length = index | ||
| push(node) | ||
| continue | ||
| } | ||
|
|
||
| flush() | ||
| push(node) | ||
| if (!selfClosing) { | ||
| stack.push({ name, line: lines.length }) | ||
| } | ||
| } | ||
|
|
||
| line += html.slice(position) | ||
| flush() | ||
|
|
||
| return lines.join('\n') | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Do not reindent generic XML or SVG responses.
formatHtmlinserts newlines and indentation between elements. In XML, including SVG, that whitespace can be significant character data. A terminal response copied after formatting can therefore differ semantically from the received response.packages/nuxt-cli/src/commands/curl.ts#L22-L22: distinguish HTML from generic XML and SVG content types.packages/nuxt-cli/src/commands/curl.ts#L303-L304: callformatHtmlonly for HTML. Highlight generic XML and SVG without rewriting their text.packages/nuxt-cli/test/unit/commands/curl.spec.ts#L285-L293: assert that XML remains unchanged after VT control characters are removed.📍 Affects 2 files
packages/nuxt-cli/src/commands/curl.ts#L22-L22(this comment)packages/nuxt-cli/src/commands/curl.ts#L303-L304packages/nuxt-cli/test/unit/commands/curl.spec.ts#L285-L293🤖 Prompt for AI Agents