Skip to content

Commit 7ef094c

Browse files
committed
Extract the App Intents strings from code at code freeze
genstrings cannot parse LocalizedStringResource, so the App Intents strings reached GlotPress through a hand-maintained mirror of the defaultValues in code, which could silently drift and ship a new string English-only. The code freeze now extracts the call sites with xcstringstool (which understands LocalizedStringResource, including comments) and merges them into the upload like the plural originals, making code the single source of truth. Translator comments move into the code via the comment: parameter, and the mirror file is deleted.
1 parent cc70f83 commit 7ef094c

5 files changed

Lines changed: 106 additions & 22 deletions

File tree

Modules/Sources/JetpackStatsWidgetsCore/AppIntents/SelectSiteIntent.swift

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ import AppIntents
88
/// users' widgets to the default site.
99
///
1010
/// The localization keys are the app-bundle names of the identifiers Xcode generated for the
11-
/// legacy `.intentdefinition` ("gpCwrM", "ILcGmf"): GlotPress uploads them under the
12-
/// `ios-widget.` prefix, and the downloaded translations land in the app's
13-
/// `Localizable.strings` under those prefixed keys.
11+
/// legacy `.intentdefinition` ("gpCwrM", "ILcGmf"): The code freeze extracts these call sites
12+
/// into the GlotPress upload (`generate_app_intents_strings_for_glotpress`), and the downloaded
13+
/// translations land in the app's `Localizable.strings` under the prefixed keys.
1414
///
1515
/// iOS 18 and later resolve the widget configuration UI's strings against the app bundle,
1616
/// whose GlotPress-managed `Localizable.strings` carries the prefixed keys in every locale
@@ -21,13 +21,25 @@ import AppIntents
2121
public struct SelectSiteIntent: WidgetConfigurationIntent, CustomIntentMigratedAppIntent {
2222
public static let intentClassName = "SelectSiteIntent"
2323

24-
public static let title = LocalizedStringResource("ios-widget.gpCwrM", defaultValue: "Select Site")
24+
public static let title = LocalizedStringResource(
25+
"ios-widget.gpCwrM",
26+
defaultValue: "Select Site",
27+
comment:
28+
"This text is used when the user is configuring the iOS widget to suggest them to select the site to configure the widget for"
29+
)
2530

2631
// The legacy intent was ineligible for Siri suggestions; keep this
2732
// configuration-only intent out of Shortcuts and Spotlight the same way.
2833
public static let isDiscoverable = false
2934

30-
@Parameter(title: LocalizedStringResource("ios-widget.ILcGmf", defaultValue: "Site"))
35+
@Parameter(
36+
title: LocalizedStringResource(
37+
"ios-widget.ILcGmf",
38+
defaultValue: "Site",
39+
comment:
40+
"This text is used when the user is configuring the iOS widget, as a label for the dropdown to select the site to configure it for"
41+
)
42+
)
3143
public var site: SiteEntity?
3244

3345
public init() {}

Modules/Sources/JetpackStatsWidgetsCore/AppIntents/SiteEntity.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@ import Foundation
1111
/// later; see `SelectSiteIntent` for the details.
1212
public struct SiteEntity: AppEntity {
1313
public static let typeDisplayRepresentation = TypeDisplayRepresentation(
14-
name: LocalizedStringResource("ios-widget.ILcGmf", defaultValue: "Site")
14+
name: LocalizedStringResource(
15+
"ios-widget.ILcGmf",
16+
defaultValue: "Site",
17+
comment:
18+
"This text is used when the user is configuring the iOS widget, as a label for the dropdown to select the site to configure it for"
19+
)
1520
)
1621

1722
public static var defaultQuery: SiteEntityQuery { SiteEntityQuery() }

WordPress/JetpackStatsWidgets/en.lproj/Localizable.strings

Lines changed: 0 additions & 14 deletions
This file was deleted.

fastlane/lanes/localization.rb

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,7 @@
9696
MANUALLY_MAINTAINED_STRINGS_FILES = {
9797
File.join('WordPress', 'Resources', 'en.lproj', 'InfoPlist.strings') => 'infoplist.', # For now WordPress and Jetpack share the same InfoPlist.strings
9898
File.join('WordPress', 'WordPressDraftActionExtension', 'en.lproj', 'InfoPlist.strings') => 'ios-sharesheet.', # CFBundleDisplayName for the "Save as Draft" share action
99-
File.join('WordPress', 'JetpackDraftActionExtension', 'en.lproj', 'InfoPlist.strings') => 'ios-jetpack-sharesheet.', # CFBundleDisplayName for the "Save to Jetpack" share action
100-
File.join('WordPress', 'JetpackStatsWidgets', 'en.lproj', 'Localizable.strings') => 'ios-widget.' # Strings for the App Intents UI used when configuring the iOS Widget; resolved at runtime via the prefixed keys in the app's Localizable.strings
99+
File.join('WordPress', 'JetpackDraftActionExtension', 'en.lproj', 'InfoPlist.strings') => 'ios-jetpack-sharesheet.' # CFBundleDisplayName for the "Save to Jetpack" share action
101100
}.freeze
102101

103102
# Remote Swift Packages whose localizable strings we want to extract (they're checked out under Derived Data
@@ -195,6 +194,21 @@ def upload_to_app_store_common_params
195194
)
196195
end
197196

197+
# Merge the App Intents strings into the same destination. These are extracted from code by
198+
# xcstringstool (genstrings cannot parse LocalizedStringResource) and are transient like the plural
199+
# originals above, but deliberately kept separate from the plurals plumbing — and unlike the plural
200+
# step this is NOT allowed to fail softly: a silently missing App Intents string would ship
201+
# English-only in every locale.
202+
Dir.mktmpdir do |app_intents_tmp|
203+
app_intents_originals = File.join(app_intents_tmp, 'AppIntents.strings')
204+
File.write(app_intents_originals, generate_app_intents_strings_for_glotpress)
205+
206+
ios_merge_strings_files(
207+
paths_to_merge: { app_intents_originals => '' }, # keys are self-qualified with their ios-* prefixes
208+
destination: File.join(WORDPRESS_EN_LPROJ, 'Localizable.strings')
209+
)
210+
end
211+
198212
git_commit(path: [WORDPRESS_EN_LPROJ], message: 'Update strings for localization', allow_nothing_to_commit: true) unless skip_commit
199213
end
200214

fastlane/lanes/localization_catalog.rb

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
require 'tmpdir'
55
require 'fileutils'
66
require_relative 'catalog_helper'
7+
require_relative 'plural_strings_helper'
78

89
#################################################
910
# Catalog generation (forward / extraction)
@@ -39,6 +40,17 @@
3940
# The custom localization routine to additionally extract (same as the genstrings `routines:` today).
4041
CATALOG_LOCALIZATION_ROUTINE = 'AppLocalizedString'
4142

43+
# App Intents display strings (LocalizedStringResource) can't be parsed by genstrings, so the code
44+
# freeze extracts them from these dirs with xcstringstool and merges them into the GlotPress upload
45+
# (see generate_app_intents_strings_for_glotpress). Keys in code are self-qualified with these prefixes.
46+
APP_INTENTS_STRINGS_ROOTS = [
47+
File.join(PROJECT_ROOT_FOLDER, 'Modules', 'Sources', 'JetpackStatsWidgetsCore', 'AppIntents')
48+
].freeze
49+
50+
APP_INTENTS_KEY_PREFIXES = [
51+
'ios-widget.'
52+
].freeze
53+
4254
platform :ios do
4355
# Extracts English source strings from code into Localizable.xcstrings (build-free; replaces genstrings).
4456
#
@@ -200,4 +212,59 @@ def report_catalog(path, extracted_count:, reconciled_count:)
200212
message += " Re-flagged #{reconciled_count} for review (English source changed)." if reconciled_count.positive?
201213
UI.success(message)
202214
end
215+
216+
# Extracts the App Intents LocalizedStringResource strings (key, English defaultValue, translator
217+
# comment) from source and returns them as `.strings` file content for the GlotPress merge. The keys
218+
# are already prefixed in code, so the caller merges this with an empty prefix, like the plural
219+
# originals. Fails loudly rather than silently shipping an untranslated intent string.
220+
# Declared as a lane like generate_plural_strings_for_glotpress: the freeze lane calls it as a
221+
# function, and it can be run standalone as a smoke check (prints the count; errors on unprefixed keys).
222+
desc 'Generates the App Intents strings content that the code freeze merges into the GlotPress upload'
223+
lane :generate_app_intents_strings_for_glotpress do
224+
files = catalog_source_files(APP_INTENTS_STRINGS_ROOTS)
225+
UI.user_error!('No App Intents source files found — APP_INTENTS_STRINGS_ROOTS out of date?') if files.empty?
226+
227+
entries = Dir.mktmpdir do |tmp|
228+
extract_stringsdata(files: files, output_dir: tmp)
229+
app_intents_entries(stringsdata_dir: tmp)
230+
end
231+
UI.user_error!('No App Intents strings extracted') if entries.empty?
232+
233+
unprefixed = entries.keys.reject { |key| APP_INTENTS_KEY_PREFIXES.any? { |prefix| key.start_with?(prefix) } }
234+
UI.user_error!("App Intents strings without a known prefix (add one, or extend APP_INTENTS_KEY_PREFIXES): #{unprefixed.sort}") unless unprefixed.empty?
235+
236+
UI.message("Extracted #{entries.count} App Intents strings for the GlotPress upload.")
237+
PluralStrings.serialize_legacy_strings(entries.sort.to_h) # sorted for stable output
238+
end
239+
240+
# { key => { value:, comment: } } from a scoped extraction, via a throwaway catalog (like
241+
# current_english_values). Entries without an explicit English value are interpolation-only
242+
# resources (e.g. DisplayRepresentation(title: "\(name)")) — not translatable text — and are skipped.
243+
def app_intents_entries(stringsdata_dir:)
244+
Dir.mktmpdir do |tmp|
245+
fresh = File.join(tmp, 'Localizable.xcstrings')
246+
File.write(fresh, "#{JSON.pretty_generate('sourceLanguage' => 'en', 'strings' => {}, 'version' => '1.0')}\n")
247+
stringsdata = stringsdata_files(stringsdata_dir)
248+
UI.user_error!('xcstringstool produced no .stringsdata for the App Intents sources') if stringsdata.empty?
249+
sh('xcrun', 'xcstringstool', 'sync', fresh, *stringsdata.flat_map { |f| ['--stringsdata', f] })
250+
251+
JSON.parse(File.read(fresh))['strings'].each_with_object({}) do |(key, entry), acc|
252+
value = entry.dig('localizations', 'en', 'stringUnit', 'value')
253+
acc[key] = { value: positionalize_untyped_arguments(value), comment: entry['comment'] } unless value.nil?
254+
end
255+
end
256+
end
257+
258+
# The build-free extraction cannot type interpolations (a defaultValue's `\(…)` segments), so it
259+
# emits untyped `%arg` placeholders. Rewrite them as positional printf specifiers (`%1$@`, `%2$@`,
260+
# …), which is what GlotPress translators and the runtime's format-style resolution expect. This is
261+
# only correct for String-valued interpolations, so App Intents defaultValues must interpolate
262+
# preformatted Strings, never raw numbers or dates (see docs/localization.md).
263+
def positionalize_untyped_arguments(value)
264+
index = 0
265+
value.gsub(/%(\d+\$)?arg/) do
266+
index += 1
267+
"%#{Regexp.last_match(1) || "#{index}$"}@"
268+
end
269+
end
203270
end

0 commit comments

Comments
 (0)