|
1 | 1 | # frozen_string_literal: true |
2 | 2 |
|
| 3 | +require 'json' |
| 4 | +require 'tmpdir' |
| 5 | +require 'fileutils' |
| 6 | +require 'open3' |
| 7 | + |
3 | 8 | ################################################# |
4 | 9 | # Constants |
5 | 10 | ################################################# |
@@ -567,4 +572,76 @@ def download_localized_app_store_metadata(glotpress_project_url:, locales:, meta |
567 | 572 | skip_confirm: skip_confirm |
568 | 573 | ) |
569 | 574 | 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 |
570 | 647 | end |
0 commit comments