Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/nuxt-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
"@nuxt/kit": "^4.5.1",
"@nuxt/schema": "^4.5.1",
"@nuxt/test-utils": "^4.1.0",
"@speed-highlight/core": "^1.2.23",
"@types/node": "^24.13.3",
"giget": "^3.3.1",
"h3": "^1.15.11",
Expand Down
120 changes: 100 additions & 20 deletions packages/nuxt-cli/src/commands/curl.ts
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'
Expand All @@ -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

Copy link
Copy Markdown

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.

formatHtml inserts 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: call formatHtml only 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-L304
  • packages/nuxt-cli/test/unit/commands/curl.spec.ts#L285-L293
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/nuxt-cli/src/commands/curl.ts` at line 22, Update
packages/nuxt-cli/src/commands/curl.ts at lines 22 and 303-304 to distinguish
HTML from generic XML and SVG content types, invoking formatHtml only for HTML
while preserving XML/SVG text and applying highlighting without rewriting it.
Update packages/nuxt-cli/test/unit/commands/curl.spec.ts at lines 285-293 to
assert that XML output remains unchanged after VT control characters are
removed.


/** 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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.ts

Repository: 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 || true

Repository: nuxt/cli

Length of output: 8921


Restore application/json-seq rendering.

CONTENT_TYPE_LANGUAGES does not map application/json-seq, so per-record JSON highlighting is lost for that media type. Add json-seq back to NDJSON_CONTENT_TYPE_RE and cover it with a regression test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/nuxt-cli/src/commands/curl.ts` at line 25, Update
NDJSON_CONTENT_TYPE_RE to recognize application/json-seq alongside the existing
NDJSON media types, restoring per-record JSON highlighting. Add a regression
test covering application/json-seq rendering.


/** 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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions packages/nuxt-cli/src/commands/task/_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { styleText } from 'node:util'
import { join, resolve } from 'pathe'

import { findDevServer, findNitroDevWorker, noDevServerMessage, resolveLockDir, toLoopback } from '../../utils/dev-server'
import { highlightJson } from '../../utils/json-highlight'
import { highlight } from '../../utils/highlight'
import { logger } from '../../utils/logger'
import { logNetworkError } from '../../utils/network'
import { getNuxtConfig } from '../../utils/nuxt-config'
Expand Down Expand Up @@ -216,7 +216,7 @@ function hasFiles(dir: string): boolean {
}

export function format(value: unknown): string {
return typeof value === 'string' ? value : highlightJson(JSON.stringify(value, null, 2))
return typeof value === 'string' ? value : highlight(JSON.stringify(value, null, 2), 'json')
}

async function request(server: TaskServer, path: string, options: RequestOptions = {}): Promise<TaskResponse> {
Expand Down
95 changes: 95 additions & 0 deletions packages/nuxt-cli/src/utils/format-html.ts
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')
}
Loading
Loading