lingara-apps 0.1.0.pre.alpha.7 → 0.1.0.pre.alpha.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0f5af9193d43817ba4dc8a956472f9f8d5a4b4ed452bf68ba5a7eeba6ed118f2
4
- data.tar.gz: a8ab344bc3dbb8135b3f45a268c632db50b0c74cf6dd8d1f5be37000d19145af
3
+ metadata.gz: fae6d6a2fd0f2ae63a10dfff6f337314be85565d58879160eb6b82b253cf072f
4
+ data.tar.gz: 7d10086620a5c492ed5c0c1f4afe2053424e0025dd6a55d8feeb02eaa336fc27
5
5
  SHA512:
6
- metadata.gz: e37178ec1db06e8ca8352be6533a451f9cb783b6fc4836553fdd41dfea40025d9f1707f0b0888a0e23e33d1c79a61df2081a8bd0c9fd3244597d93216254abb3
7
- data.tar.gz: 4c5a34c35af04aef932084bf412a6e51e1993338bf3b765dd17dd4553d9569b79089a8bddd596836c58e4c5a510b2cab09ae207ab126b71f132e64ed04e1093b
6
+ metadata.gz: e45e022cd0ee72996eed2809a5326057c3134fed1daad61eb172866dd49717e5e7e4f53209c02fbc3e13c9680ac7cac03dd791c91956c52be939d9b4f9d8c4d7
7
+ data.tar.gz: 9b20d6bd685e927ce7e8ace71ec76a6b45ee9a06ffd9eaacc2bb44a5457f468245f6038907794aa3558e499148709ae71c3883e1f15e607025e646222554b3d5
data/README.md CHANGED
@@ -101,6 +101,47 @@ you want one; the kit never calls it for you.
101
101
  breaks, and `to_json` writes `manifest.json`. A bare `name("…")` is the name
102
102
  in the default locale.
103
103
 
104
+ The listing can also carry `.long_description(…)`,
105
+ `.developer(name:, url: nil, support_url: nil)` and
106
+ `.image(sha256, "hero" | "screenshot", alt)`. An image is uploaded in the
107
+ console or the CLI first and named here by `Lingara::Apps.image_sha256(bytes)`.
108
+ These fields need a Lingara that knows them (ADR 16.10.26ah); a manifest that
109
+ sets none of them is unaffected.
110
+
111
+ ## Localising your card
112
+
113
+ Every request carries the learner's `locale`. Build one catalogue at startup
114
+ and look strings up per request:
115
+
116
+ ```ruby
117
+ MESSAGES = Lingara::Apps::Messages.new(default_locale: "en", catalogue: {
118
+ "en" => {"heading" => "Today's five", "done" => "Done ({count})"},
119
+ "zh-Hans" => {"heading" => "今日五词"}
120
+ })
121
+
122
+ t = MESSAGES.for(request.locale)
123
+ Lingara::Apps.card.heading(t.t("heading"), 1).button(t.t("done", "count" => "3"), "done").build
124
+ ```
125
+
126
+ - `Messages.new` raises `Lingara::Apps::MessagesError` at startup for a broken
127
+ catalogue, its `code` one of `:locale_invalid`, `:locale_duplicate`,
128
+ `:default_locale_missing`, `:key_missing_in_default`, never mid-render.
129
+ - The locale is chosen by `Lingara::Apps.pick_locale`: an exact match, then
130
+ the request with its last subtag dropped, then the default. `zh-Hans` never
131
+ falls to `zh-Hant`; key your catalogue by Lingara's spellings (bare
132
+ subtags, plus `zh-Hans` and `zh-Hant`).
133
+ - A key missing in the chosen locale falls back to the default's text, and an
134
+ unknown key returns the key, so a half-translated catalogue still renders a
135
+ whole card. `{name}` placeholders take String parameters; format numbers
136
+ and dates yourself with `t.locale`.
137
+ - Never choose the UI language from the `languages` context slice: that is
138
+ the learner's study pair.
139
+
140
+ A listing in one language only builds, with a warning: `builder.warnings`
141
+ (or `Lingara::Apps.manifest_warnings(json)`) is `[:single_locale]` when
142
+ `name` and `description` share no locale beyond the default. The kit never
143
+ prints it.
144
+
104
145
  ## Things to know
105
146
 
106
147
  - **The user agent.** Lingara's requests carry
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lingara
4
+ module Apps
5
+ # Localising a card (ADR 16.10.26aj, contract AK6): one locale pick, a
6
+ # message catalogue checked when built and quiet when used, and the
7
+ # listing's derived locales and warnings. Every answer here is pinned by
8
+ # conformance/vectors/locales.json and manifest-warnings.json, so the
9
+ # seven kits agree. Nothing here prints or logs: that is the caller's.
10
+
11
+ # A catalogue Messages.new refuses, at startup rather than mid-render.
12
+ # #code is one of CODES, in the order they are checked.
13
+ class MessagesError < StandardError
14
+ CODES = %i[locale_invalid locale_duplicate default_locale_missing key_missing_in_default].freeze
15
+
16
+ attr_reader :code
17
+
18
+ def initialize(code)
19
+ @code = code
20
+ super("the message catalogue is refused: #{code}")
21
+ end
22
+ end
23
+
24
+ # A message catalogue: locale => key => text, checked when built.
25
+ #
26
+ # messages = Lingara::Apps::Messages.new(default_locale: "en", catalogue: {
27
+ # "en" => {"greeting" => "Hello, {name}", "bye" => "Goodbye"},
28
+ # "ja" => {"greeting" => "こんにちは、{name}"}
29
+ # })
30
+ # t = messages.for(request.locale)
31
+ # t.t("greeting", "name" => "Ana") # also t.call(…) or t.(…)
32
+ # t.locale # the chosen locale, safe for a text element's lang
33
+ #
34
+ # Locales and keys may be Strings or Symbols; both are read as Strings.
35
+ class Messages
36
+ # One locale's lookup. A key missing in the chosen locale falls to the
37
+ # default locale's text; an unknown key returns the key itself. Never
38
+ # raises.
39
+ class Lookup
40
+ PLACEHOLDER = /\{([A-Za-z0-9_]+)\}/
41
+
42
+ # The chosen locale, safe for a text element's `lang`.
43
+ attr_reader :locale
44
+
45
+ def initialize(locale, table, fallback)
46
+ @locale = locale
47
+ @table = table
48
+ @fallback = fallback
49
+ end
50
+
51
+ # The text for +key+, its `{name}` placeholders replaced in one pass by
52
+ # the String parameter of that name. An unknown or unsupplied one, or
53
+ # one whose value is not a String, stays literal: format numbers and
54
+ # dates yourself, with #locale.
55
+ def t(key, params = {})
56
+ key = key.to_s
57
+ text = @table.fetch(key) { @fallback.fetch(key, key) }
58
+ values = params.to_h.transform_keys(&:to_s)
59
+ text.gsub(PLACEHOLDER) do |whole|
60
+ value = values[Regexp.last_match(1)]
61
+ value.is_a?(String) ? value : whole
62
+ end
63
+ end
64
+
65
+ alias_method :call, :t
66
+ end
67
+
68
+ attr_reader :default_locale
69
+
70
+ # Raises MessagesError for a broken catalogue.
71
+ def initialize(default_locale:, catalogue:)
72
+ @default_locale = default_locale.to_s
73
+ @catalogue = catalogue.to_h.to_h do |locale, table|
74
+ [locale.to_s, table.to_h.to_h { |key, text| [key.to_s, text.to_s] }]
75
+ end
76
+ code = refusal
77
+ raise MessagesError, code if code
78
+ end
79
+
80
+ # The catalogue's locales, in its own order and spelling.
81
+ def locales = @catalogue.keys
82
+
83
+ # The lookup for a request's `locale`, picked over the catalogue's locales.
84
+ def for(requested)
85
+ locale = Apps.pick_locale(requested, locales, @default_locale)
86
+ Lookup.new(locale, @catalogue.fetch(locale), @catalogue.fetch(@default_locale))
87
+ end
88
+
89
+ private
90
+
91
+ def refusal
92
+ return :locale_invalid unless (locales + [@default_locale]).all? { |l| l.match?(Limits::LANG_PATTERN) }
93
+ return :locale_duplicate if locales.map { |l| I18n.normal(l) }.uniq.size != locales.size
94
+ fallback = @catalogue[@default_locale]
95
+ return :default_locale_missing if fallback.nil?
96
+ :key_missing_in_default unless @catalogue.each_value.all? { |table| table.keys.all? { |k| fallback.key?(k) } }
97
+ end
98
+ end
99
+
100
+ # The helpers' shared parts.
101
+ module I18n
102
+ module_function
103
+
104
+ # A locale as the pick compares it: `_` read as `-`, case-folded.
105
+ def normal(locale)
106
+ locale.tr("_", "-").downcase
107
+ end
108
+
109
+ def keys(value)
110
+ value.is_a?(Hash) ? value.keys : []
111
+ end
112
+ end
113
+
114
+ # The closed list of manifest warning codes; one member in v1.
115
+ MANIFEST_WARNINGS = %i[single_locale].freeze
116
+
117
+ module_function
118
+
119
+ # The available locale for +requested+, in its available spelling: an
120
+ # exact match (case-insensitive, `_` read as `-`), then +requested+ with
121
+ # its last subtag dropped, repeated, then +default_locale+. The chain
122
+ # never widens: `pt` does not match `pt-BR`, and `zh-Hans` never reaches
123
+ # `zh-Hant`. Never raises; a nil, empty or non-String +requested+ is the
124
+ # default.
125
+ def pick_locale(requested, available, default_locale)
126
+ return default_locale unless requested.is_a?(String) && !requested.empty?
127
+ spelled = {}
128
+ available.each { |locale| spelled[I18n.normal(locale)] ||= locale }
129
+ candidate = I18n.normal(requested)
130
+ loop do
131
+ return spelled[candidate] if spelled.key?(candidate)
132
+ cut = candidate.rindex("-")
133
+ return default_locale if cut.nil?
134
+ candidate = candidate[0, cut]
135
+ end
136
+ end
137
+
138
+ # The locales the listing is translated into: where `name` and
139
+ # `description` both have an entry, and `long_description` too when
140
+ # present and not nil, sorted by byte order. [] for a manifest
141
+ # validate_manifest refuses.
142
+ def manifest_locales(manifest)
143
+ return [] if ManifestRules.first_broken(manifest)
144
+ long = manifest["long_description"]
145
+ I18n.keys(manifest["name"]).select do |locale|
146
+ I18n.keys(manifest["description"]).include?(locale) && (long.nil? || I18n.keys(long).include?(locale))
147
+ end.sort_by(&:b)
148
+ end
149
+
150
+ # What the listing could do better, never a refusal: [:single_locale]
151
+ # when it is translated into its default locale alone. [] for a refused
152
+ # manifest.
153
+ def manifest_warnings(manifest)
154
+ locales = manifest_locales(manifest)
155
+ (locales.size == 1 && locales.first == manifest["default_locale"]) ? [:single_locale] : []
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Lingara
6
+ module Apps
7
+ # The listing's three optional manifest fields (ADR 16.10.26aj D4), each
8
+ # rule mirroring Backend ADR 16.10.26ah D2–D5: `long_description`,
9
+ # `developer` and `images`, and the `locales` key a manifest never
10
+ # declares. A nil long_description, developer, url or support_url reads
11
+ # as absent; a nil images is refused.
12
+ module ListingRules
13
+ LONG_DESCRIPTION_MAX_CHARS = 1500
14
+ DEVELOPER_NAME_MAX_CHARS = 60
15
+ IMAGE_ALT_MAX_CHARS = 140
16
+ MAX_HERO_IMAGES = 1
17
+ MAX_SCREENSHOTS = 5
18
+ IMAGE_ROLES = %w[hero screenshot].freeze
19
+
20
+ # Zero-width characters, beyond the card rules' controls and bidi marks.
21
+ ZERO_WIDTH = /[​-‏]/
22
+ SHA256_HEX = /\A[0-9a-f]{64}\z/
23
+
24
+ module_function
25
+
26
+ # One text: 1..max scalar values after trimming, none of the name
27
+ # rule's characters (`\n` aside when +newlines+, which also refuses a
28
+ # run of three `\n` in the untrimmed value).
29
+ def plain_text?(value, max, newlines = false)
30
+ return false unless value.is_a?(String)
31
+ trimmed = Limits.trim(value)
32
+ return false if newlines && value.include?("\n\n\n")
33
+ trimmed.length.between?(1, max) && !Limits.control?(trimmed, newlines) && !trimmed.match?(ZERO_WIDTH)
34
+ end
35
+
36
+ # A non-empty locale map whose every value is plain_text?.
37
+ def localized?(value, max, newlines = false)
38
+ value.is_a?(Hash) && !value.empty? && value.each_value.all? { |v| plain_text?(v, max, newlines) }
39
+ end
40
+
41
+ # A locale map holding +default_locale+, as AE-1 requires of the listing's maps.
42
+ def localized_with_default?(value, default_locale, max, newlines = false)
43
+ localized?(value, max, newlines) && default_locale.is_a?(String) && value.key?(default_locale)
44
+ end
45
+
46
+ def long_description?(m)
47
+ value = m["long_description"]
48
+ value.nil? || localized_with_default?(value, m["default_locale"], LONG_DESCRIPTION_MAX_CHARS, true)
49
+ end
50
+
51
+ def developer?(m)
52
+ value = m["developer"]
53
+ return true if value.nil?
54
+ return false unless value.is_a?(Hash) && plain_text?(value["name"], DEVELOPER_NAME_MAX_CHARS)
55
+ [value["url"], value["support_url"]].all? { |u| u.nil? || (u.is_a?(String) && Limits.safe_link?(u)) }
56
+ end
57
+
58
+ def image?(entry, default_locale)
59
+ entry.is_a?(Hash) && entry["sha256"].is_a?(String) && entry["sha256"].match?(SHA256_HEX) &&
60
+ IMAGE_ROLES.include?(entry["role"]) && localized_with_default?(entry["alt"], default_locale, IMAGE_ALT_MAX_CHARS)
61
+ end
62
+
63
+ def images?(m)
64
+ return true unless m.key?("images")
65
+ value = m["images"]
66
+ return false unless value.is_a?(Array) && value.all? { |e| image?(e, m["default_locale"]) }
67
+ roles = value.map { |e| e["role"] }
68
+ roles.count("hero") <= MAX_HERO_IMAGES && roles.count("screenshot") <= MAX_SCREENSHOTS &&
69
+ value.map { |e| e["sha256"] }.uniq.size == value.size
70
+ end
71
+
72
+ # `locales` is derived by Lingara, so a manifest that declares it is refused.
73
+ def locales_absent?(m)
74
+ !m.key?("locales")
75
+ end
76
+
77
+ # The listing keys the stored manifest carries when set: nil and an
78
+ # empty images are absent.
79
+ def stored(m)
80
+ out = {}
81
+ %w[long_description developer].each { |key| out[key] = m[key] unless m[key].nil? }
82
+ out["images"] = m["images"] if m["images"].is_a?(Array) && !m["images"].empty?
83
+ out
84
+ end
85
+ end
86
+
87
+ module_function
88
+
89
+ # The lowercase hex SHA-256 of an image's bytes (a String, read as
90
+ # binary), the hash its upload is stored under.
91
+ def image_sha256(bytes)
92
+ Digest::SHA256.hexdigest(bytes)
93
+ end
94
+ end
95
+ end
@@ -25,7 +25,8 @@ module Lingara
25
25
  DESCRIPTION_MAX_CHARS = 280
26
26
  STORED_MAX_BYTES = 65_536
27
27
 
28
- RULES = %i[manifest_version default_locale name description render_url slots context duplicate too_large].freeze
28
+ RULES = %i[manifest_version default_locale name description render_url slots context long_description developer
29
+ images locales duplicate too_large].freeze
29
30
 
30
31
  # The slots a manifest may name: the generated AppSlotName's values,
31
32
  # without the generator's unknown-value placeholder.
@@ -65,13 +66,14 @@ module Lingara
65
66
  %w[slots context scopes].any? { |k| m[k].is_a?(Array) && m[k].uniq.size != m[k].size }
66
67
  end
67
68
 
68
- # The compact UTF-8 JSON of the seven stored keys, defaults filled.
69
+ # The compact UTF-8 JSON of the seven stored keys, defaults filled, and
70
+ # the listing's keys when set.
69
71
  def stored_bytes(m)
70
72
  stored = {
71
73
  "default_locale" => m["default_locale"], "name" => m["name"], "description" => m["description"],
72
74
  "slots" => m["slots"], "context" => m["context"] || [], "scopes" => m["scopes"] || [],
73
75
  "tutor_note" => m["tutor_note"].nil? ? false : m["tutor_note"]
74
- }
76
+ }.merge(ListingRules.stored(m))
75
77
  JSON.generate(stored).bytesize
76
78
  end
77
79
 
@@ -83,6 +85,10 @@ module Lingara
83
85
  render_url: ->(m) { m["render_url"].is_a?(String) && m["render_url"].match?(RENDER_URL) },
84
86
  slots: ->(m) { within?(m, "slots", SLOT_NAMES, false) },
85
87
  context: ->(m) { within?(m, "context", CONTEXT_SLICE_KINDS, true) },
88
+ long_description: ->(m) { ListingRules.long_description?(m) },
89
+ developer: ->(m) { ListingRules.developer?(m) },
90
+ images: ->(m) { ListingRules.images?(m) },
91
+ locales: ->(m) { ListingRules.locales_absent?(m) },
86
92
  duplicate: ->(m) { !duplicate?(m) },
87
93
  too_large: ->(m) { stored_bytes(m) <= STORED_MAX_BYTES }
88
94
  }.freeze
@@ -110,6 +116,9 @@ module Lingara
110
116
  @scopes = []
111
117
  @tutor_note = false
112
118
  @listed = false
119
+ @long_description = nil
120
+ @developer = nil
121
+ @images = []
113
122
  end
114
123
 
115
124
  def default_locale(locale) = set(:@default_locale, locale)
@@ -136,17 +145,38 @@ module Lingara
136
145
  # Whether the app appears in the catalogue; false unless set.
137
146
  def listed(listed = true) = set(:@listed, listed)
138
147
 
148
+ # The listing's longer text: plain, paragraphs split on blank lines. A
149
+ # locale map, or a bare String meaning the default locale's.
150
+ def long_description(text) = set(:@long_description, text)
151
+
152
+ # Who made the app, shown on its listing as the developer's own claim.
153
+ def developer(name:, url: nil, support_url: nil)
154
+ set(:@developer, {"name" => name, "url" => url, "support_url" => support_url}.compact)
155
+ end
156
+
157
+ # An image already uploaded in the console or the CLI, named by
158
+ # Lingara::Apps.image_sha256 of its bytes; +role+ is "hero" or
159
+ # "screenshot", +alt+ a locale map or a bare String.
160
+ def image(sha256, role, alt)
161
+ @images << [sha256, role.to_s, alt]
162
+ self
163
+ end
164
+
139
165
  # The manifest, or ManifestError naming the first rule it breaks.
140
166
  def build
141
- manifest = {
142
- "manifest_version" => 1, "default_locale" => @default_locale, "name" => localized(@name),
143
- "description" => localized(@description), "render_url" => @render_url, "slots" => @slots.dup,
144
- "context" => @context.dup, "scopes" => @scopes.dup, "tutor_note" => @tutor_note, "listed" => @listed
145
- }
167
+ manifest = wire
146
168
  Apps.validate_manifest(manifest)
147
169
  manifest
148
170
  end
149
171
 
172
+ # What the listing could do better, never a refusal; [] while the
173
+ # manifest would not build. Never raises.
174
+ def warnings = Apps.manifest_warnings(wire)
175
+
176
+ # The locales the listing is translated into; [] while the manifest
177
+ # would not build.
178
+ def locales = Apps.manifest_locales(wire)
179
+
150
180
  # The validated manifest as the JSON the console accepts as an upload.
151
181
  def to_json(*)
152
182
  "#{JSON.pretty_generate(build)}\n"
@@ -154,6 +184,24 @@ module Lingara
154
184
 
155
185
  private
156
186
 
187
+ def wire
188
+ {
189
+ "manifest_version" => 1, "default_locale" => @default_locale, "name" => localized(@name),
190
+ "description" => localized(@description), "render_url" => @render_url, "slots" => @slots.dup,
191
+ "context" => @context.dup, "scopes" => @scopes.dup, "tutor_note" => @tutor_note, "listed" => @listed
192
+ }.merge(listing)
193
+ end
194
+
195
+ def listing
196
+ out = {}
197
+ out["long_description"] = localized(@long_description) unless @long_description.nil?
198
+ out["developer"] = @developer.dup unless @developer.nil?
199
+ unless @images.empty?
200
+ out["images"] = @images.map { |sha, role, alt| {"sha256" => sha, "role" => role, "alt" => localized(alt)} }
201
+ end
202
+ out
203
+ end
204
+
157
205
  def set(ivar, value)
158
206
  instance_variable_set(ivar, value)
159
207
  self
@@ -3,10 +3,10 @@
3
3
  module Lingara
4
4
  module Apps
5
5
  # This kit's released version, SemVer.
6
- VERSION = "0.1.0-alpha.7"
6
+ VERSION = "0.1.0-alpha.8"
7
7
 
8
8
  # The same version in RubyGems' spelling, which the gemspec uses.
9
- GEM_VERSION = "0.1.0.pre.alpha.7"
9
+ GEM_VERSION = "0.1.0.pre.alpha.8"
10
10
 
11
11
  # The API version the app view was generated from.
12
12
  GENERATED_FROM = "2026-10-virile-dragon"
data/lib/lingara/apps.rb CHANGED
@@ -19,6 +19,8 @@ require_relative "apps/unions"
19
19
  require_relative "apps/operations"
20
20
  require_relative "apps/limits"
21
21
  require_relative "apps/card"
22
+ require_relative "apps/listing"
23
+ require_relative "apps/i18n"
22
24
  require_relative "apps/manifest"
23
25
  require_relative "apps/app"
24
26
  require_relative "apps/rack_app"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lingara-apps
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.pre.alpha.7
4
+ version: 0.1.0.pre.alpha.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Spinning Cat Studios
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-14 00:00:00.000000000 Z
11
+ date: 2026-10-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: lingara
@@ -49,7 +49,9 @@ files:
49
49
  - lib/lingara/apps/generated/app_slot_name.rb
50
50
  - lib/lingara/apps/generated/button_style.rb
51
51
  - lib/lingara/apps/generated/card.rb
52
+ - lib/lingara/apps/i18n.rb
52
53
  - lib/lingara/apps/limits.rb
54
+ - lib/lingara/apps/listing.rb
53
55
  - lib/lingara/apps/manifest.rb
54
56
  - lib/lingara/apps/operations.rb
55
57
  - lib/lingara/apps/rack_app.rb