From ff75b54d53fd03ea1bf5797b7cb8c5c44f3a49f9 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 5 Aug 2026 12:45:17 +0200 Subject: [PATCH 1/5] feat(curl,task): highlight html + json responses --- packages/nuxt-cli/package.json | 1 + packages/nuxt-cli/src/commands/curl.ts | 27 ++-- packages/nuxt-cli/src/commands/task/_utils.ts | 4 +- packages/nuxt-cli/src/utils/format-html.ts | 90 +++++++++++ packages/nuxt-cli/src/utils/highlight.ts | 151 ++++++++++++++++++ packages/nuxt-cli/src/utils/json-highlight.ts | 24 --- .../test/unit/utils/format-html.spec.ts | 65 ++++++++ .../test/unit/utils/highlight.spec.ts | 55 +++++++ .../test/unit/utils/json-highlight.spec.ts | 29 ---- packages/nuxt-cli/tsdown.config.ts | 2 +- pnpm-lock.yaml | 11 +- 11 files changed, 390 insertions(+), 69 deletions(-) create mode 100644 packages/nuxt-cli/src/utils/format-html.ts create mode 100644 packages/nuxt-cli/src/utils/highlight.ts delete mode 100644 packages/nuxt-cli/src/utils/json-highlight.ts create mode 100644 packages/nuxt-cli/test/unit/utils/format-html.spec.ts create mode 100644 packages/nuxt-cli/test/unit/utils/highlight.spec.ts delete mode 100644 packages/nuxt-cli/test/unit/utils/json-highlight.spec.ts diff --git a/packages/nuxt-cli/package.json b/packages/nuxt-cli/package.json index 866fa8702..8b96c5fab 100644 --- a/packages/nuxt-cli/package.json +++ b/packages/nuxt-cli/package.json @@ -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", diff --git a/packages/nuxt-cli/src/commands/curl.ts b/packages/nuxt-cli/src/commands/curl.ts index 59db81ce6..927346971 100644 --- a/packages/nuxt-cli/src/commands/curl.ts +++ b/packages/nuxt-cli/src/commands/curl.ts @@ -7,7 +7,8 @@ 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' @@ -15,6 +16,7 @@ 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 HTML_CONTENT_TYPE_RE = /^(?:text\/html|application\/xhtml\+xml)\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 const BINARY_SNIFF_BYTES = 4096 @@ -232,7 +234,7 @@ async function writeResponseBody(response: Response): Promise { } const text = buffer.toString('utf-8') - process.stdout.write(JSON_CONTENT_TYPE_RE.test(contentType) ? formatJson(text) : text) + process.stdout.write(formatBody(text, contentType)) if (!text.endsWith('\n')) { process.stdout.write('\n') } @@ -249,16 +251,23 @@ function isBinary(buffer: Buffer, contentType: string): boolean { return buffer.subarray(0, BINARY_SNIFF_BYTES).includes(0) } -function formatJson(text: string): string { - let json: string - try { - json = JSON.stringify(JSON.parse(text), null, 2) +function formatBody(text: string, contentType: string): string { + if (JSON_CONTENT_TYPE_RE.test(contentType)) { + let json: string + try { + json = JSON.stringify(JSON.parse(text), null, 2) + } + catch { + return text + } + return highlight(json, 'json') } - catch { - return text + + if (HTML_CONTENT_TYPE_RE.test(contentType)) { + return highlight(formatHtml(text), 'html') } - return highlightJson(json) + return text } function isJson(value: string): boolean { diff --git a/packages/nuxt-cli/src/commands/task/_utils.ts b/packages/nuxt-cli/src/commands/task/_utils.ts index 5e7bb370f..77935bf61 100644 --- a/packages/nuxt-cli/src/commands/task/_utils.ts +++ b/packages/nuxt-cli/src/commands/task/_utils.ts @@ -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' @@ -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 { diff --git a/packages/nuxt-cli/src/utils/format-html.ts b/packages/nuxt-cli/src/utils/format-html.ts new file mode 100644 index 000000000..1abf9b4b1 --- /dev/null +++ b/packages/nuxt-cli/src/utils/format-html.ts @@ -0,0 +1,90 @@ +/** 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 = /||<[!/]?[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 depth = 0 + let position = 0 + + const pad = (): string => indent.repeat(Math.max(depth, 0)) + 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(``, '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 open = stack.pop() + // A block with only inline children stays on the line it opened on. + if (open?.name === name && open.line === lines.length && line.trim() && !line.includes('\n')) { + lines[open.line - 1] += `${line.trim()}${node}` + line = '' + depth-- + continue + } + flush() + depth-- + push(node) + continue + } + + flush() + push(node) + if (!selfClosing) { + depth++ + stack.push({ name, line: lines.length }) + } + } + + line += html.slice(position) + flush() + + return lines.join('\n') +} diff --git a/packages/nuxt-cli/src/utils/highlight.ts b/packages/nuxt-cli/src/utils/highlight.ts new file mode 100644 index 000000000..6de9f043f --- /dev/null +++ b/packages/nuxt-cli/src/utils/highlight.ts @@ -0,0 +1,151 @@ +import type { ShjLanguageDefinition, ShjToken } from '@speed-highlight/core' + +import { styleText } from 'node:util' + +import cssLanguage from '@speed-highlight/core/languages/css.js' +import htmlLanguage from '@speed-highlight/core/languages/html.js' +import jsLanguage from '@speed-highlight/core/languages/js.js' +import templateLanguage, { type as templateType } from '@speed-highlight/core/languages/js_template_literals.js' +import jsdocLanguage, { type as jsdocType } from '@speed-highlight/core/languages/jsdoc.js' +import jsonLanguage from '@speed-highlight/core/languages/json.js' +import regexLanguage, { type as regexType } from '@speed-highlight/core/languages/regex.js' +import todoLanguage, { type as todoType } from '@speed-highlight/core/languages/todo.js' + +type Style = Parameters[0] + +interface Language { + sub: ShjLanguageDefinition + type?: ShjToken +} + +/** + * The languages `highlight` can be asked for, plus the ones their definitions + * reach through a nested `sub`. Importing each definition by its own subpath + * keeps the unused ones out of the bundle. + */ +const LANGUAGES: Record = { + css: { sub: cssLanguage as ShjLanguageDefinition }, + html: { sub: htmlLanguage as ShjLanguageDefinition }, + js: { sub: jsLanguage as ShjLanguageDefinition }, + js_template_literals: { sub: templateLanguage as ShjLanguageDefinition, type: templateType as ShjToken }, + jsdoc: { sub: jsdocLanguage as ShjLanguageDefinition, type: jsdocType as ShjToken }, + json: { sub: jsonLanguage as ShjLanguageDefinition }, + regex: { sub: regexLanguage as ShjLanguageDefinition, type: regexType as ShjToken }, + todo: { sub: todoLanguage as ShjLanguageDefinition, type: todoType as ShjToken }, +} + +/** Language for a nested `sub` we deliberately do not bundle: emit its text unstyled. */ +const PLAIN: Language = { sub: [] } + +/** `common.js` in `@speed-highlight/core` is not part of its export map, so the shared rules are inlined. */ +const EXPANSIONS: Record = { + num: { type: 'num', match: /(\.e?|\b)\d(e-|[\d.oxa-fA-F_])*(\.|\b)/g }, + str: { type: 'str', match: /(["'])(\\[\s\S]|(?!\1)[^\r\n\\])*\1?/g }, + strDouble: { type: 'str', match: /"((?!")[^\r\n\\]|\\[\s\S])*"?/g }, +} + +const THEME: Partial> = { + bool: 'yellow', + class: 'yellow', + cmnt: 'gray', + deleted: 'red', + err: 'red', + esc: 'cyan', + func: 'cyan', + insert: 'green', + kwd: 'magenta', + num: 'magenta', + oper: 'dim', + section: 'magenta', + str: 'green', + type: 'blue', + var: 'blue', +} + +interface Rule { + type?: ShjToken + match?: RegExp + expand?: string + sub?: string | ShjLanguageDefinition | ((code: string) => Language) +} + +type Emit = (text: string, token?: ShjToken) => void + +/** + * Walk `src` with the rules of `lang`, handing each token to `emit`. + * + * A port of `tokenize` from `@speed-highlight/core`, which is only reachable + * through an entry that statically bundles every language it ships. + */ +function tokenize(src: string, lang: Language, emit: Emit): void { + const rules = [...lang.sub] as Rule[] + const matches: ({ match: RegExpExecArray, lastIndex: number } | undefined)[] = [] + let position = 0 + + while (position < src.length) { + let best: { rule: Rule, index: number, match: string, end: number } | undefined + + for (let index = rules.length - 1; index >= 0; index--) { + const rule = rules[index]!.expand ? EXPANSIONS[rules[index]!.expand!]! : rules[index]! + const cached = matches[index] + if (!cached || cached.match.index < position) { + rule.match!.lastIndex = position + const match = rule.match!.exec(src) + if (!match) { + rules.splice(index, 1) + matches.splice(index, 1) + continue + } + matches[index] = { match, lastIndex: rule.match!.lastIndex } + } + const current = matches[index]! + if (current.match[0] && (!best || current.match.index <= best.index)) { + best = { rule, index: current.match.index, match: current.match[0], end: current.lastIndex } + } + } + + if (!best) { + break + } + + emit(src.slice(position, best.index), lang.type) + position = best.end + + const { sub } = best.rule + if (!sub) { + emit(best.match, best.rule.type) + continue + } + if (typeof sub === 'string') { + tokenize(best.match, LANGUAGES[sub] ?? PLAIN, emit) + } + else if (typeof sub === 'function') { + tokenize(best.match, sub(best.match), emit) + } + else { + tokenize(best.match, { sub, type: best.rule.type }, emit) + } + } + + emit(src.slice(position), lang.type) +} + +/** + * Colour the tokens of a document, leaving its text untouched. + * + * `styleText` writes no escapes when stdout cannot show them, so piped output + * stays parseable by `jq` and friends. + */ +export function highlight(code: string, language: 'json' | 'html'): string { + let output = '' + try { + tokenize(code, LANGUAGES[language]!, (text, token) => { + const style = token && THEME[token] + output += style && text ? styleText(style, text) : text + }) + } + catch { + return code + } + return output +} diff --git a/packages/nuxt-cli/src/utils/json-highlight.ts b/packages/nuxt-cli/src/utils/json-highlight.ts deleted file mode 100644 index 40561f71a..000000000 --- a/packages/nuxt-cli/src/utils/json-highlight.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { styleText } from 'node:util' - -const TOKEN_RE = /("(?:\\.|[^"\\])*")(\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g - -/** - * Colour the tokens of a JSON document, leaving its text untouched. - * - * `styleText` writes no escapes when stdout cannot show them, so piped output - * stays parseable by `jq` and friends. - */ -export function highlightJson(json: string): string { - return json.replace(TOKEN_RE, (match, string: string | undefined, colon: string | undefined) => { - if (string) { - return colon ? `${styleText('blue', string)}${colon}` : styleText('green', string) - } - if (match === 'null') { - return styleText('dim', match) - } - if (match === 'true' || match === 'false') { - return styleText('yellow', match) - } - return styleText('magenta', match) - }) -} diff --git a/packages/nuxt-cli/test/unit/utils/format-html.spec.ts b/packages/nuxt-cli/test/unit/utils/format-html.spec.ts new file mode 100644 index 000000000..04d26284f --- /dev/null +++ b/packages/nuxt-cli/test/unit/utils/format-html.spec.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' + +import { formatHtml } from '../../../src/utils/format-html' + +/** Whitespace between tags is the only thing `formatHtml` is allowed to rewrite. */ +function normalise(html: string): string { + return html.replace(/>\s+<').trim() +} + +describe('formatHtml', () => { + it('indents a minified document', () => { + const html = 't
  • 1
  • 2
' + + expect(formatHtml(html)).toMatchInlineSnapshot(` + " + + + t + + +
+
    +
  • 1
  • +
  • 2
  • +
+
+ + " + `) + expect(normalise(formatHtml(html))).toBe(normalise(html)) + }) + + it('keeps inline elements on the line of their text', () => { + expect(formatHtml('

abclink

')).toBe('

abclink

') + }) + + it('copies raw element bodies verbatim', () => { + const html = '
  keep\n   me  
' + + expect(formatHtml(html)).toMatchInlineSnapshot(` + "
+
  keep
+         me  
+ + + +
" + `) + }) + + it('does not confuse a `>` inside an attribute for the end of a tag', () => { + expect(formatHtml('
t
')).toBe('
t
') + }) + + it('leaves comments, self-closing tags and bare text alone', () => { + expect(formatHtml('')).toBe('\n\n \n') + expect(formatHtml('plain text')).toBe('plain text') + }) + + it('does not lose content when tags are unbalanced', () => { + expect(formatHtml('
unclosed

x

')).toContain('unclosed') + expect(formatHtml('')).toBe('') + }) +}) diff --git a/packages/nuxt-cli/test/unit/utils/highlight.spec.ts b/packages/nuxt-cli/test/unit/utils/highlight.spec.ts new file mode 100644 index 000000000..b1b492fde --- /dev/null +++ b/packages/nuxt-cli/test/unit/utils/highlight.spec.ts @@ -0,0 +1,55 @@ +import process from 'node:process' +import { stripVTControlCharacters, styleText } from 'node:util' + +import { describe, expect, it } from 'vitest' + +process.env.FORCE_COLOR = '3' + +const { highlight } = await import('../../../src/utils/highlight') + +describe('highlight', () => { + describe('json', () => { + const json = JSON.stringify({ name: 'db:seed', count: 3, ok: true, missing: null, list: [1, 'two'] }, null, 2) + + it('colours keys, strings, numbers and booleans', () => { + const highlighted = highlight(json, 'json') + + expect(highlighted).toContain(`${styleText('blue', '"name"')}: ${styleText('green', '"db:seed"')}`) + expect(highlighted).toContain(styleText('magenta', '3')) + expect(highlighted).toContain(styleText('yellow', 'true')) + expect(highlighted).toContain(styleText('magenta', 'null')) + }) + + it('leaves the document parseable', () => { + expect(JSON.parse(stripVTControlCharacters(highlight(json, 'json')))).toEqual(JSON.parse(json)) + }) + + it('does not colour inside strings that look like tokens', () => { + expect(highlight('{\n "a": "true 12 null"\n}', 'json')).toBe(`{\n ${styleText('blue', '"a"')}: ${styleText('green', '"true 12 null"')}\n}`) + }) + }) + + describe('html', () => { + const html = '\n
\n \n text\n
\n' + + it('colours tags, attributes and comments', () => { + const highlighted = highlight(html, 'html') + + expect(highlighted).toContain(styleText('blue', 'div')) + expect(highlighted).toContain(styleText('yellow', 'id')) + expect(highlighted).toContain(styleText('green', '"app"')) + expect(highlighted).toContain(styleText('gray', '')) + }) + + it('colours embedded script and style contents', () => { + const highlighted = highlight('', 'html') + + expect(highlighted).toContain(styleText('magenta', 'const')) + expect(highlighted).toContain(styleText('blue', 'color')) + }) + + it('leaves the document untouched', () => { + expect(stripVTControlCharacters(highlight(html, 'html'))).toBe(html) + }) + }) +}) diff --git a/packages/nuxt-cli/test/unit/utils/json-highlight.spec.ts b/packages/nuxt-cli/test/unit/utils/json-highlight.spec.ts deleted file mode 100644 index 3c6d7857f..000000000 --- a/packages/nuxt-cli/test/unit/utils/json-highlight.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import process from 'node:process' -import { stripVTControlCharacters, styleText } from 'node:util' - -import { describe, expect, it } from 'vitest' - -process.env.FORCE_COLOR = '3' - -const { highlightJson } = await import('../../../src/utils/json-highlight') - -describe('highlightJson', () => { - const json = JSON.stringify({ name: 'db:seed', count: 3, ok: true, missing: null, list: [1, 'two'] }, null, 2) - - it('colours keys, strings, numbers, booleans and null', () => { - const highlighted = highlightJson(json) - - expect(highlighted).toContain(`${styleText('blue', '"name"')}: ${styleText('green', '"db:seed"')}`) - expect(highlighted).toContain(styleText('magenta', '3')) - expect(highlighted).toContain(styleText('yellow', 'true')) - expect(highlighted).toContain(styleText('dim', 'null')) - }) - - it('leaves the document parseable', () => { - expect(JSON.parse(stripVTControlCharacters(highlightJson(json)))).toEqual(JSON.parse(json)) - }) - - it('does not colour inside strings that look like tokens', () => { - expect(highlightJson('{\n "a": "true 12 null"\n}')).toBe(`{\n ${styleText('blue', '"a"')}: ${styleText('green', '"true 12 null"')}\n}`) - }) -}) diff --git a/packages/nuxt-cli/tsdown.config.ts b/packages/nuxt-cli/tsdown.config.ts index 9d438579c..f71a2f9a7 100644 --- a/packages/nuxt-cli/tsdown.config.ts +++ b/packages/nuxt-cli/tsdown.config.ts @@ -12,6 +12,6 @@ export const packaging: PackagingContract = { export default defineCliConfig({ entry: ['src/index.ts', 'src/dev/index.ts'], - deps: { onlyBundle: ['h3'], neverBundle: PARSER_PACKAGES }, + deps: { onlyBundle: ['h3', '@speed-highlight/core'], neverBundle: PARSER_PACKAGES }, ...packaging, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4776e1f3..b3de3c410 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -260,6 +260,9 @@ importers: '@nuxt/test-utils': specifier: ^4.1.0 version: 4.1.0(esbuild@0.28.0)(magicast@0.5.3)(rolldown@1.2.1)(rollup@4.62.4)(typescript@6.0.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.46.2)(yaml@2.9.0))(vitest@4.1.10) + '@speed-highlight/core': + specifier: ^1.2.23 + version: 1.2.23 '@types/node': specifier: ^24.13.3 version: 24.13.3 @@ -2439,8 +2442,8 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} - '@speed-highlight/core@1.2.15': - resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==} + '@speed-highlight/core@1.2.23': + resolution: {integrity: sha512-iRoq6i6JDJP6Mt2A5JaPvzw0pgYHH6k92ij+yXiTrB7T2y9N789aWE3EHWj/5ztlJBokcCBja3iYLVdu5wgnkg==} '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -8882,7 +8885,7 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} - '@speed-highlight/core@1.2.15': {} + '@speed-highlight/core@1.2.23': {} '@standard-schema/spec@1.1.0': {} @@ -14206,7 +14209,7 @@ snapshots: dependencies: '@poppinss/colors': 4.1.6 '@poppinss/dumper': 0.7.0 - '@speed-highlight/core': 1.2.15 + '@speed-highlight/core': 1.2.23 cookie-es: 3.1.1 youch-core: 0.3.3 From a07653ca88b826daf5c665081454aeb385637745 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 5 Aug 2026 13:38:46 +0200 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20fix=20issues=20highlighted=20by=20?= =?UTF-8?q?=F0=9F=90=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/nuxt-cli/src/commands/curl.ts | 6 +++--- packages/nuxt-cli/src/utils/format-html.ts | 2 +- packages/nuxt-cli/test/unit/utils/format-html.spec.ts | 2 ++ 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/nuxt-cli/src/commands/curl.ts b/packages/nuxt-cli/src/commands/curl.ts index 927346971..6fb16efb6 100644 --- a/packages/nuxt-cli/src/commands/curl.ts +++ b/packages/nuxt-cli/src/commands/curl.ts @@ -233,9 +233,9 @@ async function writeResponseBody(response: Response): Promise { return } - const text = buffer.toString('utf-8') - process.stdout.write(formatBody(text, contentType)) - if (!text.endsWith('\n')) { + const body = formatBody(buffer.toString('utf-8'), contentType) + process.stdout.write(body) + if (!body.endsWith('\n')) { process.stdout.write('\n') } } diff --git a/packages/nuxt-cli/src/utils/format-html.ts b/packages/nuxt-cli/src/utils/format-html.ts index 1abf9b4b1..2526b9c1e 100644 --- a/packages/nuxt-cli/src/utils/format-html.ts +++ b/packages/nuxt-cli/src/utils/format-html.ts @@ -4,7 +4,7 @@ const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input' /** Elements whose content is text rather than markup, and so is never reindented. */ const RAW = new Set(['pre', 'textarea', 'script', 'style']) -const NODE_RE = /||<[!/]?[a-z][^>]*>/gi +const NODE_RE = /||<[!/]?[a-z](?:[^>"']|"[^"]*"|'[^']*')*>/gi const TAG_NAME_RE = /^<\/?\s*([a-z][\w:-]*)/i /** diff --git a/packages/nuxt-cli/test/unit/utils/format-html.spec.ts b/packages/nuxt-cli/test/unit/utils/format-html.spec.ts index 04d26284f..34b337ace 100644 --- a/packages/nuxt-cli/test/unit/utils/format-html.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/format-html.spec.ts @@ -51,6 +51,8 @@ describe('formatHtml', () => { it('does not confuse a `>` inside an attribute for the end of a tag', () => { expect(formatHtml('
t
')).toBe('
t
') + expect(formatHtml('

x

')).toBe('
\n

x

\n
') + expect(formatHtml('

x

')).toBe('
\n

x

\n
') }) it('leaves comments, self-closing tags and bare text alone', () => { From e6a59fe93e8ae39d3b64687ff4d16bb204592fc9 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 5 Aug 2026 14:44:38 +0200 Subject: [PATCH 3/5] feat: support more content types --- packages/nuxt-cli/src/commands/curl.ts | 54 ++++++++++++--- packages/nuxt-cli/src/utils/highlight.ts | 68 +++++++++++++++++-- .../nuxt-cli/test/unit/commands/curl.spec.ts | 64 +++++++++++++++++ .../test/unit/utils/highlight.spec.ts | 54 +++++++++++++++ 4 files changed, 228 insertions(+), 12 deletions(-) diff --git a/packages/nuxt-cli/src/commands/curl.ts b/packages/nuxt-cli/src/commands/curl.ts index 6fb16efb6..903931941 100644 --- a/packages/nuxt-cli/src/commands/curl.ts +++ b/packages/nuxt-cli/src/commands/curl.ts @@ -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' @@ -16,7 +18,24 @@ 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 HTML_CONTENT_TYPE_RE = /^(?:text\/html|application\/xhtml\+xml)\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|json-seq)\b/i + +/** 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'], +] const TEXT_CONTENT_TYPE_RE = /^(?:text\/|application\/(?:[\w.+-]+\+)?(?:json|xml|yaml)\b|application\/(?:javascript|ecmascript|x-www-form-urlencoded|x-ndjson)\b)/i const BINARY_SNIFF_BYTES = 4096 @@ -126,11 +145,11 @@ export default defineCommand({ } if (ctx.args.verbose) { - process.stderr.write(formatResponseHead(response, '< ')) + process.stderr.write(formatResponseHead(response, '< ', process.stderr)) } if (ctx.args.include || ctx.args.head) { - process.stdout.write(formatResponseHead(response, '')) + process.stdout.write(formatResponseHead(response, '', process.stdout)) } await writeResponseBody(response) @@ -208,10 +227,24 @@ async function readRequestBody(data: string | undefined): Promise= 500) { + return 'red' + } + if (status >= 400) { + return 'yellow' + } + if (status >= 300) { + return 'cyan' + } + return 'green' +} + +function formatResponseHead(response: Response, prefix: string, stream: NodeJS.WriteStream): string { + const status = styleText(statusStyle(response.status), `${response.status} ${response.statusText}`.trimEnd(), { stream }) + let head = `${prefix}${styleText('dim', 'HTTP/1.1', { stream })} ${status}\n` for (const [name, value] of response.headers) { - head += `${prefix}${name}: ${value}\n` + head += `${prefix}${styleText('blue', name, { stream })}: ${value}\n` } return `${head}${prefix.trimEnd()}\n` } @@ -263,11 +296,16 @@ function formatBody(text: string, contentType: string): string { return highlight(json, 'json') } - if (HTML_CONTENT_TYPE_RE.test(contentType)) { + if (NDJSON_CONTENT_TYPE_RE.test(contentType)) { + return text.replace(/[^\n]+/g, line => highlight(line, 'json')) + } + + if (MARKUP_CONTENT_TYPE_RE.test(contentType)) { return highlight(formatHtml(text), 'html') } - return text + const language = CONTENT_TYPE_LANGUAGES.find(([pattern]) => pattern.test(contentType))?.[1] + return language ? highlight(text, language) : text } function isJson(value: string): boolean { diff --git a/packages/nuxt-cli/src/utils/highlight.ts b/packages/nuxt-cli/src/utils/highlight.ts index 6de9f043f..1740ddead 100644 --- a/packages/nuxt-cli/src/utils/highlight.ts +++ b/packages/nuxt-cli/src/utils/highlight.ts @@ -2,14 +2,24 @@ import type { ShjLanguageDefinition, ShjToken } from '@speed-highlight/core' import { styleText } from 'node:util' +import bashLanguage from '@speed-highlight/core/languages/bash.js' import cssLanguage from '@speed-highlight/core/languages/css.js' +import diffLanguage from '@speed-highlight/core/languages/diff.js' import htmlLanguage from '@speed-highlight/core/languages/html.js' +import httpLanguage from '@speed-highlight/core/languages/http.js' import jsLanguage from '@speed-highlight/core/languages/js.js' import templateLanguage, { type as templateType } from '@speed-highlight/core/languages/js_template_literals.js' import jsdocLanguage, { type as jsdocType } from '@speed-highlight/core/languages/jsdoc.js' import jsonLanguage from '@speed-highlight/core/languages/json.js' +import mdLanguage from '@speed-highlight/core/languages/md.js' +import pyLanguage from '@speed-highlight/core/languages/py.js' import regexLanguage, { type as regexType } from '@speed-highlight/core/languages/regex.js' import todoLanguage, { type as todoType } from '@speed-highlight/core/languages/todo.js' +import tsLanguage from '@speed-highlight/core/languages/ts.js' +import yamlLanguage from '@speed-highlight/core/languages/yaml.js' + +/** The languages `highlight` can be asked for by name. */ +export type HighlightLanguage = 'bash' | 'css' | 'diff' | 'html' | 'http' | 'js' | 'json' | 'md' | 'py' | 'ts' | 'yaml' type Style = Parameters[0] @@ -18,24 +28,74 @@ interface Language { type?: ShjToken } +/** Language for a nested `sub` we deliberately do not bundle: emit its text unstyled. */ +const PLAIN: Language = { sub: [] } + +/** + * The HTML rules with embedded script blocks read as TypeScript, which is what + * a single-file component's `\n\n```\n', 'md') + + expect(highlighted).toContain(styleText('magenta', 'interface')) + expect(highlighted).toContain(styleText('blue', 'style')) + expect(highlighted).toContain(styleText('blue', 'color')) + }) + + it.each<[fence: string, token: string, colour: 'cyan' | 'magenta' | 'red']>([ + ['```bash\necho "hi"\n```\n', 'echo', 'cyan'], + ['```sh\necho "hi"\n```\n', 'echo', 'cyan'], + ['```diff\n- a\n+ b\n```\n', '- a', 'red'], + ['```python\ndef a(): pass\n```\n', 'def', 'magenta'], + ['```http\nGET /x HTTP/1.1\n```\n', 'GET', 'magenta'], + ])('colours a %j fence', (fence, token, colour) => { + expect(highlight(fence, 'md')).toContain(styleText(colour, token)) + }) + + it('leaves a fence in an unknown language unstyled', () => { + const highlighted = highlight('```zig\nconst a = 1\n```\n', 'md') + + expect(highlighted).toContain('\nconst a = 1\n') + }) + + it('leaves the document untouched', () => { + expect(stripVTControlCharacters(highlight(md, 'md'))).toBe(md) + }) + }) }) From 78bfd65bd5f12dfc99c39ade2a4db73ac00197ab Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 5 Aug 2026 15:49:46 +0200 Subject: [PATCH 4/5] feat: add `--pretty` flag + improve ndjson detection --- packages/nuxt-cli/src/commands/curl.ts | 83 ++++++++++++------- packages/nuxt-cli/src/utils/format-html.ts | 19 +++-- packages/nuxt-cli/src/utils/highlight.ts | 2 +- .../nuxt-cli/test/unit/commands/curl.spec.ts | 18 +++- .../test/unit/utils/format-html.spec.ts | 1 + 5 files changed, 81 insertions(+), 42 deletions(-) diff --git a/packages/nuxt-cli/src/commands/curl.ts b/packages/nuxt-cli/src/commands/curl.ts index 903931941..c8155c1ec 100644 --- a/packages/nuxt-cli/src/commands/curl.ts +++ b/packages/nuxt-cli/src/commands/curl.ts @@ -22,7 +22,7 @@ const JSON_CONTENT_TYPE_RE = /^application\/(?:[\w.+-]+\+)?json\b/i 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|json-seq)\b/i +const NDJSON_CONTENT_TYPE_RE = /^application\/(?:(?:x-)?ndjson|jsonl)\b/i /** Languages worth highlighting a response body in, keyed by content type. */ const CONTENT_TYPE_LANGUAGES: [RegExp, HighlightLanguage][] = [ @@ -36,7 +36,8 @@ const CONTENT_TYPE_LANGUAGES: [RegExp, HighlightLanguage][] = [ [/^(?:text|application)\/x-python\b/i, 'py'], [/^message\/http\b/i, 'http'], ] -const TEXT_CONTENT_TYPE_RE = /^(?:text\/|application\/(?:[\w.+-]+\+)?(?:json|xml|yaml)\b|application\/(?:javascript|ecmascript|x-www-form-urlencoded|x-ndjson)\b)/i +/** 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 @@ -89,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) @@ -144,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)) + 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)) + process.stdout.write(formatResponseHead(response, '', process.stdout, pretty)) } - await writeResponseBody(response) + await writeResponseBody(response, pretty) if (!response.ok) { process.exit(HTTP_ERROR_EXIT_CODE) @@ -240,33 +247,38 @@ function statusStyle(status: number): 'green' | 'cyan' | 'yellow' | 'red' { return 'green' } -function formatResponseHead(response: Response, prefix: string, stream: NodeJS.WriteStream): string { - const status = styleText(statusStyle(response.status), `${response.status} ${response.statusText}`.trimEnd(), { stream }) - let head = `${prefix}${styleText('dim', 'HTTP/1.1', { stream })} ${status}\n` +function formatResponseHead(response: Response, prefix: string, stream: NodeJS.WriteStream, pretty: boolean): string { + const paint = (style: Parameters[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}${styleText('blue', name, { stream })}: ${value}\n` + head += `${prefix}${paint('blue', name)}: ${value}\n` } return `${head}${prefix.trimEnd()}\n` } -async function writeResponseBody(response: Response): Promise { +async function writeResponseBody(response: Response, pretty: boolean): Promise { 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 body = formatBody(buffer.toString('utf-8'), contentType) + const text = buffer.toString('utf-8') + const body = renderer?.(text) ?? text process.stdout.write(body) if (!body.endsWith('\n')) { process.stdout.write('\n') @@ -274,38 +286,45 @@ async function writeResponseBody(response: Response): Promise { } /** - * 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 formatBody(text: string, contentType: string): string { - if (JSON_CONTENT_TYPE_RE.test(contentType)) { - let json: string - try { - json = JSON.stringify(JSON.parse(text), null, 2) - } - catch { - return text - } - return highlight(json, 'json') +function renderJson(text: string): string { + let json: string + try { + json = JSON.stringify(JSON.parse(text), null, 2) } + catch { + return text + } + return highlight(json, '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.replace(/[^\n]+/g, line => highlight(line, 'json')) + return text => text.replace(/[^\n]+/g, line => highlight(line, 'json')) } - if (MARKUP_CONTENT_TYPE_RE.test(contentType)) { - return highlight(formatHtml(text), 'html') + return text => highlight(formatHtml(text), 'html') } - const language = CONTENT_TYPE_LANGUAGES.find(([pattern]) => pattern.test(contentType))?.[1] - return language ? highlight(text, language) : text + return language ? text => highlight(text, language) : undefined } function isJson(value: string): boolean { diff --git a/packages/nuxt-cli/src/utils/format-html.ts b/packages/nuxt-cli/src/utils/format-html.ts index 2526b9c1e..92d931046 100644 --- a/packages/nuxt-cli/src/utils/format-html.ts +++ b/packages/nuxt-cli/src/utils/format-html.ts @@ -18,10 +18,9 @@ export function formatHtml(html: string, indent = ' '): string { const lines: string[] = [] const stack: { name: string, line: number }[] = [] let line = '' - let depth = 0 let position = 0 - const pad = (): string => indent.repeat(Math.max(depth, 0)) + const pad = (): string => indent.repeat(stack.length) const push = (text: string): void => { lines.push(pad() + text.replace(/\n[^\S\n]*/g, `\n${pad()}`)) } @@ -61,16 +60,23 @@ export function formatHtml(html: string, indent = ' '): string { } if (closing) { - const open = stack.pop() + 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 (open?.name === name && open.line === lines.length && line.trim() && !line.includes('\n')) { + if (index === stack.length - 1 && open.line === lines.length && line.trim() && !line.includes('\n')) { lines[open.line - 1] += `${line.trim()}${node}` line = '' - depth-- + stack.length = index continue } flush() - depth-- + stack.length = index push(node) continue } @@ -78,7 +84,6 @@ export function formatHtml(html: string, indent = ' '): string { flush() push(node) if (!selfClosing) { - depth++ stack.push({ name, line: lines.length }) } } diff --git a/packages/nuxt-cli/src/utils/highlight.ts b/packages/nuxt-cli/src/utils/highlight.ts index 1740ddead..3c44b7c5e 100644 --- a/packages/nuxt-cli/src/utils/highlight.ts +++ b/packages/nuxt-cli/src/utils/highlight.ts @@ -84,6 +84,7 @@ const ALIASES: Record = { sh: 'bash', shell: 'bash', typescript: 'ts', + xml: 'html', yml: 'yaml', zsh: 'bash', } @@ -101,7 +102,6 @@ function resolveLanguage(name: string): Language { const EXPANSIONS: Record = { num: { type: 'num', match: /(\.e?|\b)\d(e-|[\d.oxa-fA-F_])*(\.|\b)/g }, str: { type: 'str', match: /(["'])(\\[\s\S]|(?!\1)[^\r\n\\])*\1?/g }, - strDouble: { type: 'str', match: /"((?!")[^\r\n\\]|\\[\s\S])*"?/g }, } const THEME: Partial> = { diff --git a/packages/nuxt-cli/test/unit/commands/curl.spec.ts b/packages/nuxt-cli/test/unit/commands/curl.spec.ts index adde63973..fccfe48dd 100644 --- a/packages/nuxt-cli/test/unit/commands/curl.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/curl.spec.ts @@ -32,6 +32,7 @@ const ASSETS: Record = { '/sitemap.xml': ['application/xml', '/'], '/config.yaml': ['application/yaml', 'name: nuxt\nport: 3000'], '/log.ndjson': ['application/x-ndjson', '{"a":1}\n{"b":[true,null]}\n'], + '/events.ndjson': ['application/ndjson', '{"a":1}\n{"b":[true,null]}\n'], '/fix.diff': ['text/x-diff', '- const a = 1\n+ const a = 2'], '/deploy.sh': ['application/x-sh', 'echo "hi"'], '/untyped': ['text/plain', '{"hello":"world"}'], @@ -292,17 +293,30 @@ describe('curl', () => { expect(stripVTControlCharacters(stdout)).toBe('\n \n /\n \n\n') }) + it('leaves the body untouched for a terminal when --no-pretty is set', async () => { + process.stdout.isTTY = true + expect(await run([`${origin}/sitemap.xml`, '--no-pretty'])).toBe(0) + expect(stdout).toBe('/') + expect(stdout).not.toContain('\u001B[') + }) + + it('formats a piped body when --pretty is forced', async () => { + expect(await run([`${origin}/sitemap.xml`, '--pretty'])).toBe(0) + expect(stripVTControlCharacters(stdout)).toBe('\n \n /\n \n\n') + }) + it('takes a text/plain body at its word, even when it looks like json', async () => { process.stdout.isTTY = true expect(await run([`${origin}/untyped`])).toBe(0) expect(stdout).toBe('{"hello":"world"}\n') }) - it('highlights newline-delimited json a record at a time', async () => { + it.each(['/log.ndjson', '/events.ndjson'])('highlights newline-delimited json a record at a time (%s)', async (path) => { process.stdout.isTTY = true - const code = await run([`${origin}/log.ndjson`]) + const code = await run([`${origin}${path}`]) expect(code).toBe(0) + expect(stdout).toContain('\u001B[') expect(stripVTControlCharacters(stdout)).toBe('{"a":1}\n{"b":[true,null]}\n') }) diff --git a/packages/nuxt-cli/test/unit/utils/format-html.spec.ts b/packages/nuxt-cli/test/unit/utils/format-html.spec.ts index 34b337ace..38627187c 100644 --- a/packages/nuxt-cli/test/unit/utils/format-html.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/format-html.spec.ts @@ -63,5 +63,6 @@ describe('formatHtml', () => { it('does not lose content when tags are unbalanced', () => { expect(formatHtml('
unclosed

x

')).toContain('unclosed') expect(formatHtml('')).toBe('') + expect(formatHtml('

x

y

')).toBe('
\n

x

\n \n

y

\n
') }) }) From e37b5c91db8e3b02c09f8f9588aa0bece76f2848 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 5 Aug 2026 16:37:54 +0200 Subject: [PATCH 5/5] fix: respect charset in response --- packages/nuxt-cli/src/commands/curl.ts | 16 +++++++++++++++- .../nuxt-cli/test/unit/commands/curl.spec.ts | 12 ++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/nuxt-cli/src/commands/curl.ts b/packages/nuxt-cli/src/commands/curl.ts index c8155c1ec..97e0685e8 100644 --- a/packages/nuxt-cli/src/commands/curl.ts +++ b/packages/nuxt-cli/src/commands/curl.ts @@ -277,7 +277,7 @@ async function writeResponseBody(response: Response, pretty: boolean): Promise { return } + if (req.url === '/latin1') { + res.setHeader('content-type', 'text/plain; charset=iso-8859-1') + res.end(Buffer.from('café', 'latin1')) + return + } + if (req.url === '/binary') { res.setHeader('content-type', 'application/octet-stream') res.end(BINARY_BODY) @@ -305,6 +311,12 @@ describe('curl', () => { expect(stripVTControlCharacters(stdout)).toBe('\n \n /\n \n\n') }) + it('decodes the body with the charset the response declares', async () => { + process.stdout.isTTY = true + expect(await run([`${origin}/latin1`])).toBe(0) + expect(stdout).toBe('café\n') + }) + it('takes a text/plain body at its word, even when it looks like json', async () => { process.stdout.isTTY = true expect(await run([`${origin}/untyped`])).toBe(0)