← back to Cli Printing Press
fix(cli): preserve Unicode in OpenAPI display_name (#492)
67597dbb1a795ac19b5643f7cf06b266cc48ef28 · 2026-05-01 22:02:31 -0700 · Trevin Chow
* fix(cli): preserve Unicode in OpenAPI display_name
PR #371 added naming.ASCIIFold at every spec-string -> identifier chokepoint
including cleanSpecName, fixing slug and identifier portability. But OpenAPI
specs without an x-display-name extension regressed: DisplayName stayed empty,
EffectiveDisplayName fell through to HumanName(slug), and the slug was now
ASCII-folded — so "Café Bistro API" became "Cafe Bistro" and "PokéAPI" became
"Pokeapi" in manifest.json, .printing-press.json, and the MCP server identity.
Refactor cleanSpecName and toKebabCase to share their bodies via internal
helpers parameterized by foldASCII. Add cleanSpecNameUnicode that produces a
Unicode-preserving slug ("café-bistro"), used in Parse to derive DisplayName
from info.title when no x-display-name is set. Slug, binary name, env var, and
generated Go identifiers stay ASCII-folded — only display surfaces gain the
accent back. Fixes #372.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(cli): tighten display_name comments and table-drive tests
/simplify pass on the previous commit. Collapse five near-identical
parser-display-name tests into one table-driven function, drop multi-line
test docstrings whose content the test names + assertions already convey,
and trim the new exposition comments around cleanSpecNameUnicode and
specNameNoiseWords. No behavior change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M internal/openapi/parser.goM internal/openapi/parser_test.goM testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/.printing-press.jsonM testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/cmd/cafe-bistro-pp-mcp/main.goM testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/manifest.json
Diff
commit 67597dbb1a795ac19b5643f7cf06b266cc48ef28
Author: Trevin Chow <trevin@trevinchow.com>
Date: Fri May 1 22:02:31 2026 -0700
fix(cli): preserve Unicode in OpenAPI display_name (#492)
* fix(cli): preserve Unicode in OpenAPI display_name
PR #371 added naming.ASCIIFold at every spec-string -> identifier chokepoint
including cleanSpecName, fixing slug and identifier portability. But OpenAPI
specs without an x-display-name extension regressed: DisplayName stayed empty,
EffectiveDisplayName fell through to HumanName(slug), and the slug was now
ASCII-folded — so "Café Bistro API" became "Cafe Bistro" and "PokéAPI" became
"Pokeapi" in manifest.json, .printing-press.json, and the MCP server identity.
Refactor cleanSpecName and toKebabCase to share their bodies via internal
helpers parameterized by foldASCII. Add cleanSpecNameUnicode that produces a
Unicode-preserving slug ("café-bistro"), used in Parse to derive DisplayName
from info.title when no x-display-name is set. Slug, binary name, env var, and
generated Go identifiers stay ASCII-folded — only display surfaces gain the
accent back. Fixes #372.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(cli): tighten display_name comments and table-drive tests
/simplify pass on the previous commit. Collapse five near-identical
parser-display-name tests into one table-driven function, drop multi-line
test docstrings whose content the test names + assertions already convey,
and trim the new exposition comments around cleanSpecNameUnicode and
specNameNoiseWords. No behavior change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
internal/openapi/parser.go | 98 +++++++++++++++-------
internal/openapi/parser_test.go | 35 ++++++--
.../cafe-bistro/.printing-press.json | 2 +-
.../cafe-bistro/cmd/cafe-bistro-pp-mcp/main.go | 2 +-
.../cafe-bistro/manifest.json | 6 +-
5 files changed, 99 insertions(+), 44 deletions(-)
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 853d0426..79b99196 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -183,9 +183,10 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
// Extract x-display-name extension. Carries the human-readable
// brand name when it differs from the slug-derived form ("Cal.com"
- // vs "Cal Com"). Without this, the OpenAPI parser leaves
- // DisplayName empty and downstream consumers fall back to
- // naming.HumanName(slug), which deforms multi-word brands.
+ // vs "Cal Com"). When absent, derive a Unicode-preserving form from
+ // info.title so accented brands like "Café Bistro" or "PokéAPI"
+ // don't get flattened by EffectiveDisplayName's HumanName(slug)
+ // fallback (the slug is ASCII-folded for filesystem safety).
var displayName string
if doc.Info != nil && doc.Info.Extensions != nil {
if raw, ok := doc.Info.Extensions["x-display-name"]; ok {
@@ -194,6 +195,11 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
}
}
}
+ if displayName == "" && doc.Info != nil {
+ if derived := cleanSpecNameUnicode(doc.Info.Title); derived != "" && derived != "api" {
+ displayName = naming.HumanName(derived)
+ }
+ }
// Extract website URL from spec metadata (contact URL, externalDocs, or x-website)
var websiteURL string
@@ -2852,7 +2858,13 @@ func toCamelCase(s string) string {
}
func toKebabCase(input string) string {
- input = naming.ASCIIFold(input)
+ return toKebabCaseInternal(input, true)
+}
+
+func toKebabCaseInternal(input string, foldASCII bool) string {
+ if foldASCII {
+ input = naming.ASCIIFold(input)
+ }
var b strings.Builder
lastHyphen := true
@@ -2872,8 +2884,45 @@ func toKebabCase(input string) string {
return strings.Trim(b.String(), "-")
}
+// specNameNoiseWords are tokens stripped during slug derivation. Shared
+// across both fold variants so adding a word affects slug and display_name.
+var specNameNoiseWords = map[string]struct{}{
+ "swagger": {},
+ "openapi": {},
+ "rest": {},
+ "api": {},
+ "spec": {},
+ "specification": {},
+ "preview": {},
+ "http": {},
+ "with": {},
+ "and": {},
+ "from": {},
+ "for": {},
+ "the": {},
+ "by": {},
+ "of": {},
+ "in": {},
+ "on": {},
+ "to": {},
+ "fixes": {},
+ "improvements": {},
+}
+
func cleanSpecName(title string) string {
- title = naming.ASCIIFold(title)
+ return cleanSpecNameInternal(title, true)
+}
+
+// cleanSpecNameUnicode is cleanSpecName without the ASCII fold, so display_name
+// keeps accents the slug must drop for filesystem and shell safety.
+func cleanSpecNameUnicode(title string) string {
+ return cleanSpecNameInternal(title, false)
+}
+
+func cleanSpecNameInternal(title string, foldASCII bool) string {
+ if foldASCII {
+ title = naming.ASCIIFold(title)
+ }
title = strings.ToLower(strings.TrimSpace(title))
if title == "" {
return "api"
@@ -2882,8 +2931,10 @@ func cleanSpecName(title string) string {
title = strings.ReplaceAll(title, "open api", " ")
// Strip apostrophes so brand names like "Domino's" become "dominos" not
- // "domino-s". Smart-quote U+2019 already folds to ASCII via ASCIIFold above.
+ // "domino-s". When foldASCII is false, smart-quote U+2019 hasn't been
+ // folded to ASCII '\'' yet, so strip both forms.
title = strings.ReplaceAll(title, "'", "")
+ title = strings.ReplaceAll(title, "’", "")
var normalized strings.Builder
lastSpace := true
@@ -2904,35 +2955,18 @@ func cleanSpecName(title string) string {
return "api"
}
- noiseWords := map[string]struct{}{
- "swagger": {},
- "openapi": {},
- "rest": {},
- "api": {},
- "spec": {},
- "specification": {},
- "preview": {},
- "http": {},
- "with": {},
- "and": {},
- "from": {},
- "for": {},
- "the": {},
- "by": {},
- "of": {},
- "in": {},
- "on": {},
- "to": {},
- "fixes": {},
- "improvements": {},
- }
-
filtered := make([]string, 0, len(tokens))
for _, token := range tokens {
- if _, ok := noiseWords[token]; ok {
+ // The noise list is ASCII; fold for the membership check so accented
+ // variants still match, but keep the original token in the output.
+ compare := token
+ if !foldASCII {
+ compare = naming.ASCIIFold(token)
+ }
+ if _, ok := specNameNoiseWords[compare]; ok {
continue
}
- if isVersionToken(token) {
+ if isVersionToken(compare) {
continue
}
filtered = append(filtered, token)
@@ -2941,7 +2975,7 @@ func cleanSpecName(title string) string {
filtered = filtered[:3]
}
- name := toKebabCase(strings.Join(filtered, " "))
+ name := toKebabCaseInternal(strings.Join(filtered, " "), foldASCII)
if name == "" {
return "api"
}
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index f37e0f06..1a48c376 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -180,11 +180,29 @@ paths:
assert.Equal(t, "Brand Name", parsed.DisplayName)
}
-func TestParseLeavesDisplayNameEmptyWhenAbsent(t *testing.T) {
- spec := []byte(`
+// TestParseDerivesDisplayNameFromTitle locks the dual contract when no
+// x-display-name extension is set: slug is ASCII-folded for filesystem and
+// shell safety, display_name keeps Unicode for the human-facing surfaces
+// (manifest.json, .printing-press.json, MCP server identity).
+func TestParseDerivesDisplayNameFromTitle(t *testing.T) {
+ cases := []struct {
+ name string
+ title string
+ wantSlug string
+ wantDisplay string
+ }{
+ {name: "ascii", title: "Test API", wantSlug: "test", wantDisplay: "Test"},
+ {name: "precomposed_accent", title: "Café Bistro API", wantSlug: "cafe-bistro", wantDisplay: "Café Bistro"},
+ {name: "fused_diacritics", title: "Strüdel Service API", wantSlug: "strudel-service", wantDisplay: "Strüdel Service"},
+ {name: "non_latin_script", title: "東京 API", wantSlug: "dong-jing", wantDisplay: "東京"},
+ {name: "single_token_accent", title: "PokéAPI", wantSlug: "pokeapi", wantDisplay: "Pokéapi"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ spec := fmt.Appendf(nil, `
openapi: "3.0.0"
info:
- title: Test API
+ title: %s
version: "1.0"
servers:
- url: https://api.example.com
@@ -195,10 +213,13 @@ paths:
responses:
"200":
description: OK
-`)
- parsed, err := Parse(spec)
- require.NoError(t, err)
- assert.Equal(t, "", parsed.DisplayName)
+`, tc.title)
+ parsed, err := Parse(spec)
+ require.NoError(t, err)
+ assert.Equal(t, tc.wantSlug, parsed.Name)
+ assert.Equal(t, tc.wantDisplay, parsed.DisplayName)
+ })
+ }
}
func TestIsOpenAPI(t *testing.T) {
diff --git a/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/.printing-press.json b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/.printing-press.json
index 40d759c5..76a4b5d7 100644
--- a/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/.printing-press.json
+++ b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/.printing-press.json
@@ -6,7 +6,7 @@
],
"auth_type": "api_key",
"cli_name": "cafe-bistro-pp-cli",
- "display_name": "Cafe Bistro",
+ "display_name": "Café Bistro",
"generated_at": "<GENERATED_AT>",
"mcp_binary": "cafe-bistro-pp-mcp",
"mcp_ready": "full",
diff --git a/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/cmd/cafe-bistro-pp-mcp/main.go b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/cmd/cafe-bistro-pp-mcp/main.go
index 5e287c9d..03b16e68 100644
--- a/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/cmd/cafe-bistro-pp-mcp/main.go
+++ b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/cmd/cafe-bistro-pp-mcp/main.go
@@ -13,7 +13,7 @@ import (
func main() {
s := server.NewMCPServer(
- "Cafe Bistro",
+ "Café Bistro",
"1.0.0",
server.WithToolCapabilities(false),
)
diff --git a/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/manifest.json b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/manifest.json
index 6fb278a4..5bcaf49e 100644
--- a/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/manifest.json
+++ b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/manifest.json
@@ -10,8 +10,8 @@
"win32"
]
},
- "description": "Cafe Bistro API surface as MCP tools.",
- "display_name": "Cafe Bistro",
+ "description": "Café Bistro API surface as MCP tools.",
+ "display_name": "Café Bistro",
"license": "Apache-2.0",
"manifest_version": "0.3",
"name": "cafe-bistro-pp-mcp",
@@ -28,7 +28,7 @@
},
"user_config": {
"cafe_bistro_api_key_auth": {
- "description": "Sets CAFE_BISTRO_API_KEY_AUTH for the Cafe Bistro MCP server.",
+ "description": "Sets CAFE_BISTRO_API_KEY_AUTH for the Café Bistro MCP server.",
"required": true,
"sensitive": true,
"title": "CAFE_BISTRO_API_KEY_AUTH",
← 37ba0afb docs(cli): split install into clone-and-run vs marketplace p
·
back to Cli Printing Press
·
fix(cli): normalize lock name so slug and binary form share 820ba7b1 →