Skip to content

Commit a2b3217

Browse files
Don't quote non-ASCII member names in signatures
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.
1 parent 6d8c856 commit a2b3217

3 files changed

Lines changed: 102 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ title: Changelog
77
### Bug Fixes
88

99
- Anchor links in the mobile hamburger menu now correctly scroll to their target, #3049.
10+
- Member names containing non-ASCII characters are no longer rendered as quoted strings in type signatures if they are valid identifiers.
1011

1112
## v0.28.20 (2026-07-05)
1213

src/lib/output/formatter.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,35 @@ import {
1616
} from "#models";
1717
import { aggregate, assertNever, JSX } from "#utils";
1818
import { ok } from "assert";
19+
import ts from "typescript";
1920
import type { Router } from "./index.js";
2021
import { getKindClass, getUniquePath, stringify } from "./themes/lib.js";
2122

2223
// Non breaking space
2324
const INDENT = "\u00A0\u00A0\u00A0\u00A0";
2425

26+
function isIdentifierText(name: string): boolean {
27+
let codePoint = name.codePointAt(0);
28+
if (
29+
codePoint === undefined ||
30+
!ts.isIdentifierStart(codePoint, ts.ScriptTarget.Latest)
31+
) {
32+
return false;
33+
}
34+
35+
for (
36+
let offset = codePoint > 0xffff ? 2 : 1;
37+
offset < name.length;
38+
offset += codePoint > 0xffff ? 2 : 1
39+
) {
40+
codePoint = name.codePointAt(offset)!;
41+
if (!ts.isIdentifierPart(codePoint, ts.ScriptTarget.Latest)) {
42+
return false;
43+
}
44+
}
45+
return true;
46+
}
47+
2548
export type FormatterNode =
2649
| { type: "text"; content: string }
2750
| { type: "element"; content: JSX.Element; length: number }
@@ -1143,7 +1166,7 @@ export class FormattedCodeBuilder {
11431166
reflection: Reflection,
11441167
options: { topLevelLinks?: boolean },
11451168
): FormatterNode {
1146-
const entityName = /^[A-Z_$][\w$]*$/i.test(reflection.name)
1169+
const entityName = isIdentifierText(reflection.name)
11471170
? reflection.name
11481171
: JSON.stringify(reflection.name);
11491172

src/test/output/formatter.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,83 @@ describe("Formatter", () => {
621621
);
622622
});
623623

624+
it("Does not quote non-ASCII member names which are valid identifiers", () => {
625+
const refl = new DeclarationReflection(
626+
"__type",
627+
ReflectionKind.TypeLiteral,
628+
);
629+
for (const name of ["café", "Größe", "日本語", "имя", "zw\u200Cnj"]) {
630+
const child = new DeclarationReflection(
631+
name,
632+
ReflectionKind.Property,
633+
refl,
634+
);
635+
child.type = new IntrinsicType("string");
636+
refl.addChild(child);
637+
}
638+
639+
const type = new ReflectionType(refl);
640+
const text = renderElementToText(renderType(type));
641+
equal(
642+
text,
643+
"{ café: string; Größe: string; 日本語: string; имя: string; zw\u200Cnj: string }",
644+
);
645+
646+
const textWrap = renderElementToText(renderType(type, 0));
647+
equal(
648+
textWrap,
649+
dedent(`
650+
{
651+
café: string;
652+
Größe: string;
653+
日本語: string;
654+
имя: string;
655+
zw\u200Cnj: string;
656+
}
657+
`),
658+
);
659+
});
660+
661+
it("Quotes member names which are not valid identifiers", () => {
662+
const refl = new DeclarationReflection(
663+
"__type",
664+
ReflectionKind.TypeLiteral,
665+
);
666+
for (const name of ["[iterator]", "0", "a-b", "with space"]) {
667+
const child = new DeclarationReflection(
668+
name,
669+
ReflectionKind.Property,
670+
refl,
671+
);
672+
child.type = new IntrinsicType("string");
673+
refl.addChild(child);
674+
}
675+
676+
const type = new ReflectionType(refl);
677+
const text = renderElementToText(renderType(type));
678+
equal(
679+
text,
680+
`{ "[iterator]": string; "0": string; "a-b": string; "with space": string }`,
681+
);
682+
});
683+
684+
it("Uses TypeScript's identifier table", () => {
685+
const refl = new DeclarationReflection(
686+
"__type",
687+
ReflectionKind.TypeLiteral,
688+
);
689+
const child = new DeclarationReflection(
690+
"\u088F",
691+
ReflectionKind.Property,
692+
refl,
693+
);
694+
child.type = new IntrinsicType("string");
695+
refl.addChild(child);
696+
697+
const type = new ReflectionType(refl);
698+
equal(renderElementToText(renderType(type)), `{ "\u088F": string }`);
699+
});
700+
624701
it("Handles rest types", () => {
625702
const type = new RestType(new LiteralType("x"));
626703
const text = renderElementToText(renderType(type));

0 commit comments

Comments
 (0)