From a2b3217955f0b983230ae53b72e345c67012df68 Mon Sep 17 00:00:00 2001 From: David PAVLOVSCHII Date: Wed, 29 Jul 2026 01:23:04 +0300 Subject: [PATCH] Don't quote non-ASCII member names in signatures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FormattedCodeBuilder.propertyName tested names against an ASCII-only regular expression, so valid identifiers like café or 日本語 were rendered as quoted string literals. Validate each code point with TypeScript's public identifier predicates instead. This accepts exactly the same Unicode identifier table as the supported TypeScript version, including astral-plane characters, without depending on the host Node.js ICU tables. --- CHANGELOG.md | 1 + src/lib/output/formatter.tsx | 25 +++++++++- src/test/output/formatter.test.ts | 77 +++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f662ec1b1..2a3c89083 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ title: Changelog ### Bug Fixes - Anchor links in the mobile hamburger menu now correctly scroll to their target, #3049. +- Member names containing non-ASCII characters are no longer rendered as quoted strings in type signatures if they are valid identifiers. ## v0.28.20 (2026-07-05) diff --git a/src/lib/output/formatter.tsx b/src/lib/output/formatter.tsx index 47454b5d8..964a8c0e4 100644 --- a/src/lib/output/formatter.tsx +++ b/src/lib/output/formatter.tsx @@ -16,12 +16,35 @@ import { } from "#models"; import { aggregate, assertNever, JSX } from "#utils"; import { ok } from "assert"; +import ts from "typescript"; import type { Router } from "./index.js"; import { getKindClass, getUniquePath, stringify } from "./themes/lib.js"; // Non breaking space const INDENT = "\u00A0\u00A0\u00A0\u00A0"; +function isIdentifierText(name: string): boolean { + let codePoint = name.codePointAt(0); + if ( + codePoint === undefined || + !ts.isIdentifierStart(codePoint, ts.ScriptTarget.Latest) + ) { + return false; + } + + for ( + let offset = codePoint > 0xffff ? 2 : 1; + offset < name.length; + offset += codePoint > 0xffff ? 2 : 1 + ) { + codePoint = name.codePointAt(offset)!; + if (!ts.isIdentifierPart(codePoint, ts.ScriptTarget.Latest)) { + return false; + } + } + return true; +} + export type FormatterNode = | { type: "text"; content: string } | { type: "element"; content: JSX.Element; length: number } @@ -1143,7 +1166,7 @@ export class FormattedCodeBuilder { reflection: Reflection, options: { topLevelLinks?: boolean }, ): FormatterNode { - const entityName = /^[A-Z_$][\w$]*$/i.test(reflection.name) + const entityName = isIdentifierText(reflection.name) ? reflection.name : JSON.stringify(reflection.name); diff --git a/src/test/output/formatter.test.ts b/src/test/output/formatter.test.ts index cfe2f4a4b..e574db49d 100644 --- a/src/test/output/formatter.test.ts +++ b/src/test/output/formatter.test.ts @@ -621,6 +621,83 @@ describe("Formatter", () => { ); }); + it("Does not quote non-ASCII member names which are valid identifiers", () => { + const refl = new DeclarationReflection( + "__type", + ReflectionKind.TypeLiteral, + ); + for (const name of ["café", "Größe", "日本語", "имя", "zw\u200Cnj"]) { + const child = new DeclarationReflection( + name, + ReflectionKind.Property, + refl, + ); + child.type = new IntrinsicType("string"); + refl.addChild(child); + } + + const type = new ReflectionType(refl); + const text = renderElementToText(renderType(type)); + equal( + text, + "{ café: string; Größe: string; 日本語: string; имя: string; zw\u200Cnj: string }", + ); + + const textWrap = renderElementToText(renderType(type, 0)); + equal( + textWrap, + dedent(` + { + café: string; + Größe: string; + 日本語: string; + имя: string; + zw\u200Cnj: string; + } + `), + ); + }); + + it("Quotes member names which are not valid identifiers", () => { + const refl = new DeclarationReflection( + "__type", + ReflectionKind.TypeLiteral, + ); + for (const name of ["[iterator]", "0", "a-b", "with space"]) { + const child = new DeclarationReflection( + name, + ReflectionKind.Property, + refl, + ); + child.type = new IntrinsicType("string"); + refl.addChild(child); + } + + const type = new ReflectionType(refl); + const text = renderElementToText(renderType(type)); + equal( + text, + `{ "[iterator]": string; "0": string; "a-b": string; "with space": string }`, + ); + }); + + it("Uses TypeScript's identifier table", () => { + const refl = new DeclarationReflection( + "__type", + ReflectionKind.TypeLiteral, + ); + const child = new DeclarationReflection( + "\u088F", + ReflectionKind.Property, + refl, + ); + child.type = new IntrinsicType("string"); + refl.addChild(child); + + const type = new ReflectionType(refl); + equal(renderElementToText(renderType(type)), `{ "\u088F": string }`); + }); + it("Handles rest types", () => { const type = new RestType(new LiteralType("x")); const text = renderElementToText(renderType(type));