[object Object]

← 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

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 →