Skip to content

Commit cee64c6

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")` 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"` (recursing the compound value so `#if DEBUG`-gated previews are caught too). No source parsing or brace-tracking, and tautologically consistent with what extraction pulls. Self-contained (no dependency on the #25688 catalog lane); validated adversarially with zero false positives. Documents the `verbatim:` rule and the gate in docs/localization.md.
1 parent 0901c01 commit cee64c6

4 files changed

Lines changed: 144 additions & 0 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
#!/bin/bash -eu
2+
3+
echo "--- :rubygems: Setting up Gems"
4+
install_gems
5+
6+
# The Fastfile `before_all` hook requires project.env to exist; the example copy is enough for this lint
7+
# (it reads no secrets — only source files + xcstringstool).
8+
echo "--- :writing_hand: Copy Files"
9+
mkdir -pv ~/.configure/wordpress-ios/secrets
10+
cp -v fastlane/env/project.env-example ~/.configure/wordpress-ios/secrets/project.env
11+
12+
echo "--- :sleuth_or_spy: Linting SwiftUI preview strings"
13+
bundle exec fastlane lint_swiftui_preview_strings

.buildkite/pipeline.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,13 @@ steps:
163163
- github_commit_status:
164164
context: "Verify String Catalog Coverage"
165165

166+
- label: ":sleuth_or_spy: Lint SwiftUI Preview Strings"
167+
command: .buildkite/commands/lint-swiftui-preview-strings.sh
168+
plugins: [$CI_TOOLKIT_PLUGIN]
169+
notify:
170+
- github_commit_status:
171+
context: "Lint SwiftUI Preview Strings"
172+
166173
#################
167174
# Claude Build Analysis - dynamically uploaded so Build result conditions evaluate at runtime after the wait
168175
#################

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: 109 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
#################################################
@@ -589,4 +594,108 @@ def download_localized_app_store_metadata(glotpress_project_url:, locales:, meta
589594
skip_confirm: skip_confirm
590595
)
591596
end
597+
598+
#################################################
599+
# SwiftUI preview-string lint
600+
#
601+
# Fails if a localizable SwiftUI `Text("…")` literal lives inside a `#Preview` or `PreviewProvider`.
602+
# Preview code never ships to users, but Apple's extractor (`xcstringstool extract --SwiftUI-Text`)
603+
# still pulls those literals into the String Catalog and feeds them to translators as garbage. The
604+
# only correct opt-out is `Text(verbatim:)` (Apple has no preview-specific exclusion).
605+
#
606+
# This reuses the extractor as its own oracle: `xcstringstool` tags every extracted string's
607+
# `visibility`, and preview literals come out as `visibility: "preview"` — so no source parsing or
608+
# brace-tracking is required, and the lint is tautologically consistent with what extraction does.
609+
#
610+
# Scope (validated adversarially — zero false positives, so it never blocks an innocent PR):
611+
# • Catches `Text("…")` written *lexically inside* a `#Preview`/`PreviewProvider` (any nesting depth,
612+
# plus `#if`-gated previews via `preview_visibility?`).
613+
# • Known gaps (by design, not bugs): a preview-only string hoisted into a free helper view/func is
614+
# indistinguishable from shipping code, so it isn't tagged `preview`; and modern localizable APIs
615+
# (`String(localized:)`, `LocalizedStringResource`) aren't on the `--SwiftUI-Text` surface. Both are
616+
# latent today (the cleanup is `Text("…")`-only) — revisit if/when those patterns appear.
617+
#################################################
618+
619+
desc 'Fails if a localizable SwiftUI Text("…") literal lives in a #Preview (use Text(verbatim:) instead)'
620+
lane :lint_swiftui_preview_strings do
621+
roots = [
622+
File.join(PROJECT_ROOT_FOLDER, 'WordPress'),
623+
File.join(PROJECT_ROOT_FOLDER, 'Modules', 'Sources')
624+
]
625+
files = swiftui_lint_source_files(roots)
626+
UI.user_error!('No Swift source files found to lint') if files.empty?
627+
UI.message("Scanning #{files.count} Swift files for localizable Text(\"\") literals in previews…")
628+
629+
offenders = preview_localizable_text_literals(files)
630+
631+
if offenders.empty?
632+
UI.success('No localizable Text("…") literals found in SwiftUI previews. ✅')
633+
else
634+
offenders.each { |offender| UI.error(" #{offender}") }
635+
UI.user_error!(
636+
"#{offenders.count} localizable SwiftUI `Text(\"\")` literal(s) found in `#Preview` / `PreviewProvider`. " \
637+
'Preview strings must not be extracted for translation — wrap each in `Text(verbatim:)`.'
638+
)
639+
end
640+
end
641+
642+
# Enumerate .swift files under the given roots, applying the same exclusions as the string-extraction flow
643+
# (vendored code, the unit-test harness, and AppLocalizedString.swift's own definition).
644+
def swiftui_lint_source_files(roots)
645+
roots.flat_map { |root| Dir.glob(File.join(root, '**', '*.swift')) }
646+
.reject { |path| path.include?('Vendor/') || path.include?('/WordPressTest/') || File.basename(path) == 'AppLocalizedString.swift' }
647+
.uniq
648+
.sort
649+
end
650+
651+
# Run `xcstringstool extract --SwiftUI-Text` over the files and return a sorted "file:line \"key\"" entry
652+
# for every extracted string tagged `visibility: preview` (i.e. originating from #Preview / PreviewProvider).
653+
def preview_localizable_text_literals(files)
654+
Dir.mktmpdir do |dir|
655+
extract_swiftui_text_stringsdata(files, dir)
656+
Dir.glob(File.join(dir, '**', '*.stringsdata')).flat_map { |path| preview_offenders_in(path) }.sort
657+
end
658+
end
659+
660+
# Extract `--SwiftUI-Text` .stringsdata for all files into `dir`, chunked to stay under ARG_MAX.
661+
# `--omit-empty-stringsdata` skips writing a file per source with no strings.
662+
def extract_swiftui_text_stringsdata(files, dir)
663+
files.each_slice(400).with_index do |chunk, index|
664+
chunk_dir = File.join(dir, "chunk-#{index}")
665+
FileUtils.mkdir_p(chunk_dir)
666+
_out, err, status = Open3.capture3('xcrun', 'xcstringstool', 'extract', *chunk, '--SwiftUI-Text', '--omit-empty-stringsdata', '--output-directory', chunk_dir)
667+
UI.user_error!("xcstringstool extract failed:\n#{err}") unless status.success?
668+
end
669+
end
670+
671+
# Parse one .stringsdata (a property list) into "file:line \"key\"" entries for its preview-visibility strings.
672+
# Scans every table (not just Localizable) so a preview using `Text("…", tableName:)` is still caught.
673+
def preview_offenders_in(stringsdata_path)
674+
json, status = Open3.capture2('plutil', '-convert', 'json', '-o', '-', stringsdata_path)
675+
return [] unless status.success?
676+
677+
data = JSON.parse(json)
678+
source = data['source'].to_s.sub("#{PROJECT_ROOT_FOLDER}/", '')
679+
(data['tables'] || {}).values.flatten
680+
.select { |entry| preview_visibility?(entry['visibility']) }
681+
.map { |entry| offender_label(source, entry) }
682+
end
683+
684+
# "file:line \"key\"" label for a single offending stringsdata entry.
685+
def offender_label(source, entry)
686+
"#{source}:#{entry.dig('location', 'startingLine')} #{entry['key'].inspect}"
687+
end
688+
689+
# True if a string's `visibility` denotes a SwiftUI preview. `xcstringstool` emits a bare "preview" for a
690+
# plain `#Preview`/`PreviewProvider`, but wraps it when the preview sits under conditional compilation —
691+
# e.g. `#if DEBUG` → `#Preview` yields {"type":"conjunction","values":[{"type":"defined","value":"DEBUG"},
692+
# "preview"]}. Recurse the values so a `#if DEBUG`-gated preview is caught too. A bare `#if FLAG` (no
693+
# preview) is {"type":"defined","value":"FLAG"} with no "preview" leaf, so it stays correctly unflagged.
694+
def preview_visibility?(visibility)
695+
case visibility
696+
when 'preview' then true
697+
when Hash then Array(visibility['values']).any? { |value| preview_visibility?(value) }
698+
else false
699+
end
700+
end
592701
end

0 commit comments

Comments
 (0)