Skip to content

Commit d91db67

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 69b9947 commit d91db67

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

0 commit comments

Comments
 (0)