Skip to content

Commit f2b3b7d

Browse files
committed
Localization: fail CI when a localizable Text("…") lives in a #Preview
Adds a `lint_swiftui_preview_strings` fastlane lane and a "Lint SwiftUI Preview Strings" Buildkite step so preview strings can't regress: a `#Preview` shipping a translatable `Text("literal")` now fails CI with the exact file:line and the fix (wrap in `Text(verbatim:)`). Reuses Apple's extractor as its own oracle — `xcstringstool extract --SwiftUI-Text` tags every extracted string's `visibility`, and #Preview / PreviewProvider literals come out as `visibility: "preview"`. No source parsing or brace-tracking, and tautologically consistent with what extraction pulls. Self-contained (no dependency on the #25688 catalog lane). Green on the current tree; verified it fails on an injected preview literal. Documents the `verbatim:` rule and the gate in docs/localization.md.
1 parent 69b9947 commit f2b3b7d

4 files changed

Lines changed: 106 additions & 0 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#!/bin/bash -eu
2+
3+
echo "--- :rubygems: Setting up Gems"
4+
install_gems
5+
6+
echo "--- :sleuth_or_spy: Linting SwiftUI preview strings"
7+
bundle exec fastlane lint_swiftui_preview_strings

.buildkite/pipeline.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,13 @@ steps:
130130
command: .buildkite/commands/lint-localized-strings-format.sh
131131
plugins: [$CI_TOOLKIT_PLUGIN]
132132

133+
- label: ":sleuth_or_spy: Lint SwiftUI Preview Strings"
134+
command: .buildkite/commands/lint-swiftui-preview-strings.sh
135+
plugins: [$CI_TOOLKIT_PLUGIN]
136+
notify:
137+
- github_commit_status:
138+
context: "Lint SwiftUI Preview Strings"
139+
133140
#################
134141
# Claude Build Analysis - dynamically uploaded so Build result conditions evaluate at runtime after the wait
135142
#################

docs/localization.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,21 @@ Use `SharedStrings` (@WordPress/Classes/Utility/SharedStrings.swift) for common
8383
let localizedCount = NumberFormatter.localizedString(from: NSNumber(value: count), number: .none)
8484
```
8585

86+
## Non-localizable text and previews
87+
88+
A SwiftUI `Text("literal")` uses `LocalizedStringKey`, so the literal is **automatically extracted for translation**. For strings that must *not* be translated — glyphs and symbols (`Text("·")`), brand names (`Text("WordPress.com")`), pure interpolations (`Text("@\(username)")`), and any text in `#Preview` / `PreviewProvider` / developer-only screens — use `Text(verbatim:)` so the string is excluded:
89+
90+
```swift
91+
Text(verbatim: "·") // divider glyph — not translatable
92+
Text(verbatim: "WordPress.com") // brand name — not translatable
93+
94+
#Preview {
95+
Text(verbatim: "Sample Card") // preview placeholder — never ships, must be verbatim
96+
}
97+
```
98+
99+
This is the only opt-out — Apple has no preview- or `#if DEBUG`-specific exclusion. **A localizable `Text("…")` literal inside a `#Preview` or `PreviewProvider` fails CI** (the "Lint SwiftUI Preview Strings" step / `bundle exec fastlane lint_swiftui_preview_strings`); wrap it in `Text(verbatim:)` to fix.
100+
86101
## Organization Pattern
87102

88103
Organize strings using private enums within each view or view model:

fastlane/lanes/localization.rb

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# frozen_string_literal: true
22

3+
require 'json'
4+
require 'tmpdir'
5+
require 'fileutils'
6+
require 'open3'
7+
38
#################################################
49
# Constants
510
#################################################
@@ -567,4 +572,76 @@ def download_localized_app_store_metadata(glotpress_project_url:, locales:, meta
567572
skip_confirm: skip_confirm
568573
)
569574
end
575+
576+
#################################################
577+
# SwiftUI preview-string lint
578+
#
579+
# Fails if a localizable SwiftUI `Text("…")` literal lives inside a `#Preview` or `PreviewProvider`.
580+
# Preview code never ships to users, but Apple's extractor (`xcstringstool extract --SwiftUI-Text`)
581+
# still pulls those literals into the String Catalog and feeds them to translators as garbage. The
582+
# only correct opt-out is `Text(verbatim:)` (Apple has no preview-specific exclusion).
583+
#
584+
# This reuses the extractor as its own oracle: `xcstringstool` tags every extracted string's
585+
# `visibility`, and preview literals come out as `visibility: "preview"` — so no source parsing or
586+
# brace-tracking is required, and the lint is tautologically consistent with what extraction does.
587+
#################################################
588+
589+
desc 'Fails if a localizable SwiftUI Text("…") literal lives in a #Preview (use Text(verbatim:) instead)'
590+
lane :lint_swiftui_preview_strings do
591+
roots = [
592+
File.join(PROJECT_ROOT_FOLDER, 'WordPress'),
593+
File.join(PROJECT_ROOT_FOLDER, 'Modules', 'Sources')
594+
]
595+
files = swiftui_lint_source_files(roots)
596+
UI.user_error!('No Swift source files found to lint') if files.empty?
597+
UI.message("Scanning #{files.count} Swift files for localizable Text(\"\") literals in previews…")
598+
599+
offenders = preview_localizable_text_literals(files)
600+
601+
if offenders.empty?
602+
UI.success('No localizable Text("…") literals found in SwiftUI previews. ✅')
603+
else
604+
offenders.each { |offender| UI.error(" #{offender}") }
605+
UI.user_error!(
606+
"#{offenders.count} localizable SwiftUI `Text(\"\")` literal(s) found in `#Preview` / `PreviewProvider`. " \
607+
'Preview strings must not be extracted for translation — wrap each in `Text(verbatim:)`.'
608+
)
609+
end
610+
end
611+
612+
# Enumerate .swift files under the given roots, applying the same exclusions as the string-extraction flow
613+
# (vendored code, the unit-test harness, and AppLocalizedString.swift's own definition).
614+
def swiftui_lint_source_files(roots)
615+
roots.flat_map { |root| Dir.glob(File.join(root, '**', '*.swift')) }
616+
.reject { |path| path.include?('Vendor/') || path.include?('/WordPressTest/') || File.basename(path) == 'AppLocalizedString.swift' }
617+
.uniq
618+
.sort
619+
end
620+
621+
# Run `xcstringstool extract --SwiftUI-Text` over the files and return a sorted "file:line \"key\"" entry
622+
# for every extracted string tagged `visibility: preview` (i.e. originating from #Preview / PreviewProvider).
623+
def preview_localizable_text_literals(files)
624+
Dir.mktmpdir do |dir|
625+
# Chunked to stay under ARG_MAX; `--omit-empty-stringsdata` skips writing a file per source with no strings.
626+
files.each_slice(400).with_index do |chunk, index|
627+
chunk_dir = File.join(dir, "chunk-#{index}")
628+
FileUtils.mkdir_p(chunk_dir)
629+
_out, err, status = Open3.capture3('xcrun', 'xcstringstool', 'extract', *chunk, '--SwiftUI-Text', '--omit-empty-stringsdata', '--output-directory', chunk_dir)
630+
UI.user_error!("xcstringstool extract failed:\n#{err}") unless status.success?
631+
end
632+
633+
Dir.glob(File.join(dir, '**', '*.stringsdata')).flat_map do |path|
634+
# .stringsdata is a property list on disk; convert to JSON to read it.
635+
json, status = Open3.capture2('plutil', '-convert', 'json', '-o', '-', path)
636+
next [] unless status.success?
637+
638+
data = JSON.parse(json)
639+
source = data['source'].to_s.sub("#{PROJECT_ROOT_FOLDER}/", '')
640+
# Scan every table (not just Localizable) so a preview using `Text("…", tableName:)` is still caught.
641+
(data['tables'] || {}).values.flatten
642+
.select { |entry| entry['visibility'] == 'preview' }
643+
.map { |entry| "#{source}:#{entry.dig('location', 'startingLine')} #{entry['key'].inspect}" }
644+
end.sort
645+
end
646+
end
570647
end

0 commit comments

Comments
 (0)