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));