poetry-core 0.0.2

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.
Files changed (166) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3 -0
  3. data/DESIGN.md +145 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +30 -0
  6. data/THIRD_PARTY_NOTICES.md +507 -0
  7. data/app/components/poetry/core/box/component.rb +82 -0
  8. data/app/components/poetry/core/component.rb +390 -0
  9. data/app/components/poetry/core/concerns/agent_tools.rb +418 -0
  10. data/app/components/poetry/core/concerns/declared_attributes.rb +267 -0
  11. data/app/components/poetry/core/concerns/introspection.rb +533 -0
  12. data/app/components/poetry/core/concerns/options.rb +262 -0
  13. data/app/components/poetry/core/concerns/parts.rb +148 -0
  14. data/app/components/poetry/core/concerns/stimulus.rb +363 -0
  15. data/app/components/poetry/core/concerns/styles.rb +323 -0
  16. data/app/components/poetry/core/style.rb +150 -0
  17. data/app/components/poetry/core/wrapper/component.rb +90 -0
  18. data/app/javascript/poetry/core/accordion_controller.js +165 -0
  19. data/app/javascript/poetry/core/action_bar_controller.js +109 -0
  20. data/app/javascript/poetry/core/autocomplete_controller.js +220 -0
  21. data/app/javascript/poetry/core/calendar_controller.js +406 -0
  22. data/app/javascript/poetry/core/carousel_controller.js +167 -0
  23. data/app/javascript/poetry/core/checkbox_group_controller.js +97 -0
  24. data/app/javascript/poetry/core/checked_controller.js +208 -0
  25. data/app/javascript/poetry/core/clipboard_text_controller.js +111 -0
  26. data/app/javascript/poetry/core/combobox_controller.js +1095 -0
  27. data/app/javascript/poetry/core/command_controller.js +510 -0
  28. data/app/javascript/poetry/core/context_menu_controller.js +204 -0
  29. data/app/javascript/poetry/core/date_field_controller.js +584 -0
  30. data/app/javascript/poetry/core/date_picker_controller.js +111 -0
  31. data/app/javascript/poetry/core/deferred_controller.js +111 -0
  32. data/app/javascript/poetry/core/dialog_controller.js +206 -0
  33. data/app/javascript/poetry/core/dismissable_controller.js +182 -0
  34. data/app/javascript/poetry/core/drawer_controller.js +365 -0
  35. data/app/javascript/poetry/core/file_input_controller.js +191 -0
  36. data/app/javascript/poetry/core/focus_scope_controller.js +162 -0
  37. data/app/javascript/poetry/core/helpers/announce.js +228 -0
  38. data/app/javascript/poetry/core/helpers/breakpoint.js +28 -0
  39. data/app/javascript/poetry/core/helpers/collection.js +18 -0
  40. data/app/javascript/poetry/core/helpers/direction.js +15 -0
  41. data/app/javascript/poetry/core/helpers/escape.js +38 -0
  42. data/app/javascript/poetry/core/helpers/filter_rank.js +104 -0
  43. data/app/javascript/poetry/core/helpers/focus_guards.js +46 -0
  44. data/app/javascript/poetry/core/helpers/hotkey.js +43 -0
  45. data/app/javascript/poetry/core/helpers/id_integrity.js +65 -0
  46. data/app/javascript/poetry/core/helpers/incomplete_date.js +328 -0
  47. data/app/javascript/poetry/core/helpers/mask.js +283 -0
  48. data/app/javascript/poetry/core/helpers/portal.js +217 -0
  49. data/app/javascript/poetry/core/helpers/presence.js +210 -0
  50. data/app/javascript/poetry/core/helpers/registration_guard.js +76 -0
  51. data/app/javascript/poetry/core/helpers/scroll_lock.js +67 -0
  52. data/app/javascript/poetry/core/helpers/scroller_geometry.js +422 -0
  53. data/app/javascript/poetry/core/helpers/state.js +80 -0
  54. data/app/javascript/poetry/core/helpers/tabbable.js +56 -0
  55. data/app/javascript/poetry/core/helpers/turbo_cache.js +21 -0
  56. data/app/javascript/poetry/core/helpers/typeahead.js +85 -0
  57. data/app/javascript/poetry/core/hotkey_controller.js +55 -0
  58. data/app/javascript/poetry/core/hover_card_controller.js +431 -0
  59. data/app/javascript/poetry/core/index.js +218 -0
  60. data/app/javascript/poetry/core/mask_controller.js +500 -0
  61. data/app/javascript/poetry/core/menu_controller.js +1050 -0
  62. data/app/javascript/poetry/core/menubar_controller.js +329 -0
  63. data/app/javascript/poetry/core/message_scroller_controller.js +930 -0
  64. data/app/javascript/poetry/core/navigation_menu_controller.js +458 -0
  65. data/app/javascript/poetry/core/number_field_controller.js +404 -0
  66. data/app/javascript/poetry/core/optimistic_form_controller.js +56 -0
  67. data/app/javascript/poetry/core/otp_controller.js +219 -0
  68. data/app/javascript/poetry/core/popover_controller.js +300 -0
  69. data/app/javascript/poetry/core/popper_controller.js +439 -0
  70. data/app/javascript/poetry/core/pressed_controller.js +74 -0
  71. data/app/javascript/poetry/core/questionnaire_controller.js +468 -0
  72. data/app/javascript/poetry/core/radio_group_controller.js +197 -0
  73. data/app/javascript/poetry/core/resizable_controller.js +202 -0
  74. data/app/javascript/poetry/core/roving_focus_controller.js +215 -0
  75. data/app/javascript/poetry/core/scroll_spy_controller.js +84 -0
  76. data/app/javascript/poetry/core/search_field_controller.js +95 -0
  77. data/app/javascript/poetry/core/select_controller.js +924 -0
  78. data/app/javascript/poetry/core/sensitive_input_controller.js +195 -0
  79. data/app/javascript/poetry/core/sheet_controller.js +24 -0
  80. data/app/javascript/poetry/core/sidebar_controller.js +272 -0
  81. data/app/javascript/poetry/core/slider_controller.js +419 -0
  82. data/app/javascript/poetry/core/state_controller.js +82 -0
  83. data/app/javascript/poetry/core/table_selection_controller.js +153 -0
  84. data/app/javascript/poetry/core/tabs_controller.js +151 -0
  85. data/app/javascript/poetry/core/tag_group_controller.js +134 -0
  86. data/app/javascript/poetry/core/toast_controller.js +228 -0
  87. data/app/javascript/poetry/core/toast_trigger_controller.js +26 -0
  88. data/app/javascript/poetry/core/toaster_controller.js +197 -0
  89. data/app/javascript/poetry/core/toggle_group_controller.js +206 -0
  90. data/app/javascript/poetry/core/tooltip_controller.js +541 -0
  91. data/app/javascript/poetry/core/tree_controller.js +265 -0
  92. data/app/javascript/poetry/core/vendor/floating_ui_core.js +1043 -0
  93. data/app/javascript/poetry/core/vendor/floating_ui_dom.js +794 -0
  94. data/app/javascript/poetry/core/vendor/floating_ui_utils.js +141 -0
  95. data/app/javascript/poetry/core/vendor/floating_ui_utils_dom.js +170 -0
  96. data/app/views/poetry/core/preview.html.erb +13 -0
  97. data/config/component_registry.yml +20 -0
  98. data/config/controllers_manifest.json +1528 -0
  99. data/config/importmap.rb +11 -0
  100. data/config/state_vocabulary.json +31 -0
  101. data/lib/active_model/type/list.rb +33 -0
  102. data/lib/active_model/type/symbol.rb +36 -0
  103. data/lib/poetry/core/check/stable_identity.rb +104 -0
  104. data/lib/poetry/core/check.rb +1445 -0
  105. data/lib/poetry/core/config.rb +286 -0
  106. data/lib/poetry/core/contrib/wrapped_helper.rb +68 -0
  107. data/lib/poetry/core/css/bem_merger.rb +45 -0
  108. data/lib/poetry/core/css/bem_reference.rb +87 -0
  109. data/lib/poetry/core/css/override_scan.rb +110 -0
  110. data/lib/poetry/core/css/resolver.rb +161 -0
  111. data/lib/poetry/core/css/safelist.rb +41 -0
  112. data/lib/poetry/core/css/tailwind_merger.rb +102 -0
  113. data/lib/poetry/core/css/template_classes.rb +102 -0
  114. data/lib/poetry/core/css/theme_coverage.rb +72 -0
  115. data/lib/poetry/core/css/var_coverage.rb +75 -0
  116. data/lib/poetry/core/css/verifier.rb +106 -0
  117. data/lib/poetry/core/design_lint.rb +720 -0
  118. data/lib/poetry/core/design_md/import.rb +250 -0
  119. data/lib/poetry/core/design_md.rb +461 -0
  120. data/lib/poetry/core/engine.rb +71 -0
  121. data/lib/poetry/core/errors.rb +53 -0
  122. data/lib/poetry/core/html/attributes.rb +667 -0
  123. data/lib/poetry/core/icons.rb +157 -0
  124. data/lib/poetry/core/llms_text.rb +354 -0
  125. data/lib/poetry/core/page_architectures.rb +304 -0
  126. data/lib/poetry/core/part_contract.rb +285 -0
  127. data/lib/poetry/core/preview/abstract.rb +121 -0
  128. data/lib/poetry/core/preview/base.rb +227 -0
  129. data/lib/poetry/core/preview/sidecarable.rb +136 -0
  130. data/lib/poetry/core/preview/template.rb +186 -0
  131. data/lib/poetry/core/recipe_items.rb +82 -0
  132. data/lib/poetry/core/registry.rb +363 -0
  133. data/lib/poetry/core/registry_address.rb +115 -0
  134. data/lib/poetry/core/registry_client.rb +217 -0
  135. data/lib/poetry/core/registry_installer.rb +234 -0
  136. data/lib/poetry/core/registry_items.rb +184 -0
  137. data/lib/poetry/core/skill_text.rb +288 -0
  138. data/lib/poetry/core/stable_id.rb +110 -0
  139. data/lib/poetry/core/stimulus/builder.rb +395 -0
  140. data/lib/poetry/core/stimulus/declarations.rb +444 -0
  141. data/lib/poetry/core/stimulus/manifest.rb +68 -0
  142. data/lib/poetry/core/stimulus/merger.rb +131 -0
  143. data/lib/poetry/core/stimulus_contract.rb +263 -0
  144. data/lib/poetry/core/tag_helper.rb +10 -0
  145. data/lib/poetry/core/template_compile.rb +74 -0
  146. data/lib/poetry/core/token_import.rb +317 -0
  147. data/lib/poetry/core/tokens/color.rb +215 -0
  148. data/lib/poetry/core/tokens/contrast_gate.rb +120 -0
  149. data/lib/poetry/core/tokens/generator.rb +211 -0
  150. data/lib/poetry/core/tokens.rb +117 -0
  151. data/lib/poetry/core/version.rb +9 -0
  152. data/lib/poetry/core.rb +52 -0
  153. data/lib/poetry-core.rb +5 -0
  154. data/tokens/aliases.css +145 -0
  155. data/tokens/tailwind-theme.css +47 -0
  156. data/tokens/tokens.css +78 -0
  157. data/tokens/tokens.dtcg.json +779 -0
  158. data/vendor/floating-ui/LICENSE +20 -0
  159. data/vendor/floating-ui/VENDORED_VERSIONS +15 -0
  160. data/vendor/shadcn-tailwind/LICENSE +21 -0
  161. data/vendor/shadcn-tailwind/VENDORED_COMMIT +1 -0
  162. data/vendor/shadcn-tailwind/tailwind.css +629 -0
  163. data/vendor/tw-animate-css/LICENSE +21 -0
  164. data/vendor/tw-animate-css/VENDORED_VERSION +1 -0
  165. data/vendor/tw-animate-css/tw-animate.css +1 -0
  166. metadata +267 -0
@@ -0,0 +1,461 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Poetry
6
+ module Core
7
+ # DESIGN.md interop: poetry WRITES and READS the design-skill
8
+ # ecosystem's shared artifact - the DESIGN.md file that design tools
9
+ # emit, author, and read, formalized by an open spec
10
+ # (github.com/google-labs-code/design.md, alpha).
11
+ #
12
+ # Serialized files carry both interop surfaces at once:
13
+ #
14
+ # - YAML front matter in the spec's shape (`version`/`name`/flat
15
+ # `colors:`/`typography:`/`rounded:`), plus namespaced extensions the
16
+ # spec tolerates: `modes.dark` (poetry ships BOTH modes) and
17
+ # a `poetry:` block (source-of-truth pointers, contrast policy, theme
18
+ # treatment) that makes parse -> serialize lossless;
19
+ # - Markdown sections in the spec's canonical order (its linter checks
20
+ # section-order), readable by humans and section-walking skills.
21
+ #
22
+ # `parse` is deliberately more tolerant than `serialize`: poetry-authored
23
+ # files rebuild exactly from the front matter (round-trip guarantee:
24
+ # serialize(parse(md)) is byte-identical for files serialize emitted);
25
+ # foreign files fall back to the section walker (heading variants across
26
+ # the ecosystems, colors in hex/rgb/oklch). What cannot be parsed lands
27
+ # in doc["unknown"] - the import pipeline DROPS it with a report,
28
+ # never guesses.
29
+ #
30
+ # @example
31
+ # doc = Poetry::Core::DesignMd.parse(File.read("DESIGN.md"))
32
+ # doc.dig("colors", "light", "primary") # => a Tokens::Color
33
+ #
34
+ # @api private
35
+ class DesignMd
36
+ # The spec's canonical section order (its own linter warns on
37
+ # out-of-order sections).
38
+ SECTIONS = ["Overview", "Colors", "Typography", "Layout", "Elevation & Depth",
39
+ "Shapes", "Components", "Do's and Don'ts", "Intentional deviations"].freeze
40
+
41
+ COMPONENTS_POINTER = "/poetry/llms.txt"
42
+
43
+ # Heading classifier for the tolerant walker - the spellings emitting
44
+ # tools actually use (including British "colour").
45
+ HEADING_KINDS = {
46
+ colors: /\b(?:colou?rs?|palette)\b/i,
47
+ typography: /\b(?:typography|type(?:\s+pairing)?|fonts?)\b/i,
48
+ shapes: /\b(?:shapes?|radius|radii|corners?|rounded)\b/i
49
+ }.freeze
50
+
51
+ # A color-looking CSS value anywhere in a line.
52
+ COLOR_VALUE = /(#\h{3,8}\b|rgba?\([^)]*\)|oklch\([^)]*\))/i
53
+ # `name: value` in bullets, definition lines, or `| name | value |` rows.
54
+ NAME_VALUE_LINE = %r{\A\s*(?:[-*]\s+)?(?:\*\*)?([A-Za-z][\w /-]*?)(?:\*\*)?\s*[:=]\s*(.+)\z}
55
+
56
+ class << self
57
+ # Build a document hash from the live token model. `details` carries
58
+ # the per-theme presentation metadata: "typography" (pairing/family -
59
+ # metadata only, no poetry theme moves a font token),
60
+ # "treatment", "components_count", optional "description".
61
+ # `deviations`: the host's declared .cn-* overrides from
62
+ # config/poetry_components.yml - design intent, not drift, so the
63
+ # export carries them (the poetry: front matter round-trips them).
64
+ def build(tokens:, theme:, details:, deviations: [])
65
+ gate = Tokens::ContrastGate.new(tokens)
66
+ {
67
+ "name" => "poetry #{theme}",
68
+ "theme" => theme,
69
+ "description" => details["description"] ||
70
+ "poetry #{theme} - semantic role tokens (shared across every poetry theme) " \
71
+ "under the #{theme} component treatment.",
72
+ "colors" => tokens.modes.to_h do |mode|
73
+ [mode, tokens.color_names(mode).to_h { |name| [name, tokens.color(mode, name)] }]
74
+ end,
75
+ "typography" => details.fetch("typography"),
76
+ "radius" => tokens.radius_css,
77
+ "radius_scale" => Tokens::Generator::RADIUS_SCALE.dup,
78
+ "contrast" => {
79
+ "floor" => "WCAG 2.2 AA (4.5:1) - locked, every gated pair",
80
+ "target" => "AAA (7:1) wherever achievable at lock time",
81
+ "gate" => "Poetry::Core::Tokens::ContrastGate",
82
+ "aa_exceptions" => gate.results.select { |result| result.lock == :aa }.map(&:to_s)
83
+ },
84
+ "treatment" => details.fetch("treatment"),
85
+ "components" => { "count" => details.fetch("components_count"), "pointer" => COMPONENTS_POINTER },
86
+ "generator" => details["generator"],
87
+ "deviations" => (deviations unless Array(deviations).empty?)
88
+ }.compact
89
+ end
90
+
91
+ def serialize(doc)
92
+ "#{front_matter(doc)}#{body(doc)}"
93
+ end
94
+
95
+ def parse(markdown)
96
+ fm, prose = split_front_matter(markdown.to_s)
97
+ return from_poetry_front_matter(fm) if fm&.key?("poetry")
98
+
99
+ from_foreign(fm, prose)
100
+ end
101
+
102
+ # --- serialization -------------------------------------------------
103
+
104
+ def front_matter(doc)
105
+ light = doc.dig("colors", "light") || {}
106
+ dark = doc.dig("colors", "dark") || {}
107
+ data = {
108
+ "version" => "alpha",
109
+ "name" => doc["name"],
110
+ "description" => doc["description"],
111
+ "colors" => light.transform_values(&:css),
112
+ "modes" => { "dark" => dark.transform_values(&:css) },
113
+ "typography" => { "body" => { "fontFamily" => doc.dig("typography", "family") } },
114
+ "rounded" => resolved_rounded(doc),
115
+ "poetry" => {
116
+ "theme" => doc["theme"],
117
+ "source" => Tokens::DEFAULT_RELATIVE_PATH,
118
+ "generator" => doc["generator"],
119
+ "dark_mode" => "class .dark",
120
+ "radius" => doc["radius"],
121
+ "radius_scale" => doc["radius_scale"],
122
+ "typography_pairing" => doc.dig("typography", "pairing"),
123
+ "treatment" => doc["treatment"],
124
+ "components_count" => doc.dig("components", "count"),
125
+ "components_pointer" => doc.dig("components", "pointer"),
126
+ "contrast_policy" => doc["contrast"],
127
+ "deviations" => doc["deviations"]
128
+ }.compact
129
+ }
130
+ "#{YAML.dump(data)}---\n"
131
+ end
132
+
133
+ # The radius scale resolved to concrete px (rem at 16px/em) - the
134
+ # spec's `rounded:` wants plain dimensions foreign tools can consume;
135
+ # the calc() truth stays under poetry.radius_scale.
136
+ def resolved_rounded(doc)
137
+ base_px = radius_px(doc["radius"])
138
+ return {} unless base_px
139
+
140
+ (doc["radius_scale"] || {}).to_h do |step, value|
141
+ factor = value[/\*\s*([\d.]+)\s*\)/, 1]&.to_f || 1.0
142
+ [step, "#{format("%g", base_px * factor)}px"]
143
+ end
144
+ end
145
+
146
+ def radius_px(radius)
147
+ number = radius.to_s[/[\d.]+/]&.to_f
148
+ return unless number
149
+
150
+ radius.to_s.include?("rem") ? number * 16 : number
151
+ end
152
+
153
+ def body(doc)
154
+ [overview_section(doc), colors_section(doc), typography_section(doc),
155
+ layout_section, elevation_section(doc), shapes_section(doc),
156
+ components_section(doc), dos_and_donts_section,
157
+ deviations_section(doc)].compact.join("\n")
158
+ end
159
+
160
+ def overview_section(doc)
161
+ <<~MD
162
+ # DESIGN.md - #{doc["name"]}
163
+
164
+ #{doc["description"]}
165
+
166
+ ## Overview
167
+
168
+ - design system: poetry - server-rendered Rails components (ViewComponent + Stimulus + Tailwind v4)
169
+ - theme: #{doc["theme"]} - #{doc["treatment"]}
170
+ - token source: one DTCG file shared by every poetry theme (`#{Tokens::DEFAULT_RELATIVE_PATH}`);
171
+ themes are component-treatment layers, not palettes
172
+ - dark mode: the `.dark` class - both modes ship in this file
173
+ MD
174
+ end
175
+
176
+ def colors_section(doc)
177
+ light = doc.dig("colors", "light") || {}
178
+ dark = doc.dig("colors", "dark") || {}
179
+ rows = light.keys.map { |name| "| #{name} | #{light[name].css} | #{dark[name]&.css} |" }
180
+ <<~MD
181
+ ## Colors
182
+
183
+ Semantic roles only - components never reference raw palette values
184
+ (the class verifier and `poetry check` enforce this).
185
+
186
+ | role | light | dark |
187
+ |---|---|---|
188
+ #{rows.join("\n")}
189
+ MD
190
+ end
191
+
192
+ def typography_section(doc)
193
+ <<~MD
194
+ ## Typography
195
+
196
+ - pairing: #{doc.dig("typography", "pairing")} (app-level metadata - no poetry theme
197
+ moves a font token)
198
+ - family: #{doc.dig("typography", "family")}
199
+ MD
200
+ end
201
+
202
+ def layout_section
203
+ <<~MD
204
+ ## Layout
205
+
206
+ - spacing: the Tailwind v4 default scale (0.25rem base unit) - poetry adds no spacing tokens
207
+ - structure: components size to their container; page layout stays host-owned
208
+ MD
209
+ end
210
+
211
+ def elevation_section(doc)
212
+ <<~MD
213
+ ## Elevation & Depth
214
+
215
+ - shadows and overlay scrims are theme treatment (the `cn-*` layer), not tokens -
216
+ #{doc["theme"]} ships its own elevation story
217
+ MD
218
+ end
219
+
220
+ def shapes_section(doc)
221
+ scale = (doc["radius_scale"] || {}).map { |step, value| "#{step} #{value}" }.join(" - ")
222
+ <<~MD
223
+ ## Shapes
224
+
225
+ - radius: #{doc["radius"]} (`--radius`)
226
+ - scale: #{scale}
227
+ MD
228
+ end
229
+
230
+ def components_section(doc)
231
+ <<~MD
232
+ ## Components
233
+
234
+ - catalog: #{doc.dig("components", "count")} components - the registry is the truth; read
235
+ `#{doc.dig("components", "pointer")}` (or `llms-full.txt` with events and variants)
236
+ instead of duplicating it here
237
+ - consume via `poetry_*` helpers; styling rides the theme's `cn-*` classes,
238
+ never per-instance CSS
239
+ MD
240
+ end
241
+
242
+ def dos_and_donts_section
243
+ <<~MD
244
+ ## Do's and Don'ts
245
+
246
+ - Do pick variants by intent - one `primary` action per view; `destructive`
247
+ only for irreversible actions.
248
+ - Do keep both modes honest - every surface must hold in light and dark.
249
+ - Don't hand-write hex/oklch in markup - use role tokens (`bg-primary`,
250
+ `text-destructive`, ...); `poetry check` flags raw color classes.
251
+ - Don't paint white text on solid `destructive` in dark mode - dark
252
+ destructive renders at 60% over the background.
253
+ - Don't introduce new colors, shadows, or radii without adding tokens first.
254
+ MD
255
+ end
256
+
257
+ # Declared .cn-* overrides: rendered only when the host has
258
+ # any - the nine gem exports stay byte-stable.
259
+ def deviations_section(doc)
260
+ deviations = Array(doc["deviations"])
261
+ return nil if deviations.empty?
262
+
263
+ rows = deviations.map do |entry|
264
+ files = Array(entry["files"]).join(", ")
265
+ "| `#{entry["cn"]}` | #{files.empty? ? "-" : "`#{files}`"} | #{entry["reason"]} | #{entry["created"]} |"
266
+ end
267
+ <<~MD
268
+ ## Intentional deviations
269
+
270
+ Declared `.cn-*` overrides from `config/poetry_components.yml` - design
271
+ intent, reviewed and reasoned; anything else touching `cn-*` from host
272
+ CSS is drift (`bin/rails poetry:design:overrides`).
273
+
274
+ | cn | files | reason | created |
275
+ |----|-------|--------|---------|
276
+ #{rows.join("\n")}
277
+ MD
278
+ end
279
+
280
+ # --- parsing ---------------------------------------------------------
281
+
282
+ def split_front_matter(markdown)
283
+ match = markdown.match(/\A---\n(.*?)\n---\n?(.*)\z/m)
284
+ return [nil, markdown] unless match
285
+
286
+ [YAML.safe_load(match[1], permitted_classes: [], aliases: true), match[2]]
287
+ rescue Psych::SyntaxError
288
+ [nil, markdown]
289
+ end
290
+
291
+ # A file serialize emitted: everything rebuilds from the front matter,
292
+ # so serialize(parse(md)) == md.
293
+ def from_poetry_front_matter(front)
294
+ meta = front["poetry"]
295
+ light, light_unknown = parse_color_map(front["colors"])
296
+ dark, dark_unknown = parse_color_map(front.dig("modes", "dark"))
297
+ {
298
+ "name" => front["name"],
299
+ "theme" => meta["theme"],
300
+ "description" => front["description"],
301
+ "colors" => { "light" => light, "dark" => dark },
302
+ "typography" => { "pairing" => meta["typography_pairing"],
303
+ "family" => front.dig("typography", "body", "fontFamily") },
304
+ "radius" => meta["radius"],
305
+ "radius_scale" => meta["radius_scale"],
306
+ "contrast" => meta["contrast_policy"],
307
+ "treatment" => meta["treatment"],
308
+ "components" => { "count" => meta["components_count"], "pointer" => meta["components_pointer"] },
309
+ "generator" => meta["generator"],
310
+ "deviations" => meta["deviations"],
311
+ "unknown" => { "colors" => light_unknown.merge(dark_unknown), "sections" => [] }
312
+ }
313
+ end
314
+
315
+ # A foreign DESIGN.md (another tool's export, or hand-authored):
316
+ # front matter where present, then the section walker over the prose.
317
+ def from_foreign(front, prose)
318
+ front ||= {}
319
+ light, unknown_colors = parse_color_map(front["colors"])
320
+ walked = walk_sections(prose)
321
+
322
+ walked[:colors].each do |name, value|
323
+ next if light.key?(name)
324
+
325
+ color = Tokens::Color.parse(value)
326
+ color ? light[name] = color : unknown_colors[name] = value
327
+ end
328
+
329
+ {
330
+ "name" => front["name"],
331
+ "theme" => nil,
332
+ "description" => front["description"],
333
+ "colors" => { "light" => light, "dark" => walked[:dark_colors] },
334
+ "typography" => { "pairing" => nil,
335
+ "family" => front.dig("typography", "body", "fontFamily") || walked[:family] },
336
+ "radius" => foreign_radius(front) || walked[:radius],
337
+ "radius_scale" => nil,
338
+ "contrast" => nil,
339
+ "treatment" => nil,
340
+ "components" => nil,
341
+ "unknown" => { "colors" => unknown_colors.merge(walked[:unknown_colors]),
342
+ "sections" => walked[:unknown_sections] }
343
+ }
344
+ end
345
+
346
+ # Flat name => css-string map -> [{name => Color}, {name => raw}].
347
+ def parse_color_map(map)
348
+ parsed = {}
349
+ unknown = {}
350
+ (map || {}).each do |name, value|
351
+ color = Tokens::Color.parse(value)
352
+ color ? parsed[normalize_name(name)] = color : unknown[normalize_name(name)] = value
353
+ end
354
+ [parsed, unknown]
355
+ end
356
+
357
+ def foreign_radius(front)
358
+ rounded = front["rounded"]
359
+ return rounded.values.first&.to_s if rounded.is_a?(Hash) && rounded.any?
360
+
361
+ rounded&.to_s
362
+ end
363
+
364
+ # The tolerant section walker: headings classified by HEADING_KINDS,
365
+ # `name: value` lines mined inside each. Returns raw findings; the
366
+ # importer decides what maps onto poetry roles.
367
+ def walk_sections(prose)
368
+ result = { colors: {}, dark_colors: {}, unknown_colors: {}, unknown_sections: [],
369
+ family: nil, radius: nil }
370
+ each_section(prose) do |heading, lines|
371
+ kind = HEADING_KINDS.find { |_kind, pattern| heading.match?(pattern) }&.first
372
+ case kind
373
+ when :colors then walk_colors(lines, result)
374
+ when :typography then walk_typography(lines, result)
375
+ when :shapes then walk_shapes(lines, result)
376
+ else result[:unknown_sections] << heading
377
+ end
378
+ end
379
+ result
380
+ end
381
+
382
+ def each_section(prose)
383
+ heading = nil
384
+ lines = []
385
+ prose.each_line do |line|
386
+ if (match = line.match(/\A\#{1,6}\s+(.+?)\s*\z/))
387
+ yield(heading, lines) if heading
388
+ heading = match[1]
389
+ lines = []
390
+ else
391
+ lines << line.chomp
392
+ end
393
+ end
394
+ yield(heading, lines) if heading
395
+ end
396
+
397
+ def walk_colors(lines, result)
398
+ table_modes = nil
399
+ lines.each do |line|
400
+ if line.strip.start_with?("|")
401
+ table_modes = color_table_row(line, table_modes, result)
402
+ elsif (match = line.match(NAME_VALUE_LINE))
403
+ assign_color(result, match[1], match[2])
404
+ end
405
+ end
406
+ end
407
+
408
+ # Handles `| role | light | dark |` and `| name | value |` tables.
409
+ # Returns the sniffed header layout so data rows know their columns.
410
+ def color_table_row(line, table_modes, result)
411
+ cells = line.split("|").map(&:strip).reject(&:empty?)
412
+ return table_modes if cells.all? { |cell| cell.match?(/\A:?-+:?\z/) } # separator row
413
+
414
+ if table_modes.nil?
415
+ header = cells.map(&:downcase)
416
+ return { light: header.index("light"), dark: header.index("dark") } if header.include?("light")
417
+
418
+ return { light: 1, dark: nil } # value column follows the name
419
+ end
420
+
421
+ name = cells[0]
422
+ assign_color(result, name, cells[table_modes[:light]].to_s)
423
+ if table_modes[:dark] && (dark = Tokens::Color.parse(cells[table_modes[:dark]].to_s))
424
+ result[:dark_colors][normalize_name(name)] = dark
425
+ end
426
+ table_modes
427
+ end
428
+
429
+ def assign_color(result, name, raw)
430
+ value = raw[COLOR_VALUE, 1] || raw.strip
431
+ color = Tokens::Color.parse(value)
432
+ key = normalize_name(name)
433
+ color ? result[:colors][key] = value : result[:unknown_colors][key] = raw.strip
434
+ end
435
+
436
+ def walk_typography(lines, result)
437
+ lines.each do |line|
438
+ match = line.match(NAME_VALUE_LINE)
439
+ next unless match
440
+ next unless match[1].match?(/\A(?:font[\s-]?family|family|body|pairing|type)\z/i)
441
+
442
+ result[:family] ||= match[2].strip
443
+ end
444
+ end
445
+
446
+ def walk_shapes(lines, result)
447
+ lines.each do |line|
448
+ next unless line.match?(/radius|rounded/i)
449
+
450
+ dimension = line[/([\d.]+(?:px|rem|em))/, 1]
451
+ result[:radius] ||= dimension if dimension
452
+ end
453
+ end
454
+
455
+ def normalize_name(name)
456
+ name.to_s.strip.downcase.gsub(/\s+/, "-")
457
+ end
458
+ end
459
+ end
460
+ end
461
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+ # Rails::Engine::Configuration references ActionDispatch::Routing::RouteSet as
5
+ # soon as `config` is touched (below), so action_dispatch must be loaded for the
6
+ # engine to be requirable standalone (e.g. via `require "poetry/core"`), not only
7
+ # inside an already-booted Rails app.
8
+ require "action_dispatch"
9
+
10
+ module Poetry
11
+ module Core
12
+ # The Rails engine: wires the component autoload paths, previews,
13
+ # importmap pins, asset paths, and the StableId / TagHelper mixins into
14
+ # a host app at boot.
15
+ class Engine < ::Rails::Engine
16
+ # isolate_namespace Poetry::Core
17
+
18
+ config.poetry_core = Poetry::Core::Config.current
19
+
20
+ # Add components to autoload paths for Zeitwerk
21
+ config.autoload_paths << "#{Poetry::Core.root}/app/components"
22
+ config.eager_load_paths << "#{Poetry::Core.root}/app/components"
23
+
24
+ initializer "poetry_core.stable_id" do
25
+ ActiveSupport.on_load(:action_controller) do
26
+ include Poetry::Core::StableId::Controller
27
+ end
28
+ end
29
+
30
+ initializer "poetry_core.tag_helper" do
31
+ ActiveSupport.on_load(:action_view) do
32
+ include Poetry::Core::TagHelper
33
+ end
34
+ end
35
+
36
+ initializer "poetry_core.previews" do
37
+ ActiveSupport.on_load(:view_component) do
38
+ ViewComponent::Preview.extend Poetry::Core::Preview::Sidecarable
39
+ end
40
+ end
41
+
42
+ initializer "poetry_core.view_component" do |app|
43
+ app.config.view_component.previews.paths << "#{Poetry::Core.root}/app/components"
44
+ end
45
+
46
+ # Lookbook is a dev-only dependency; guard so the engine never crashes a
47
+ # production (or lean test) host that does not load it.
48
+ initializer "poetry_core.setup_lookbook" do |app|
49
+ app.config.lookbook.preview_paths << "#{Poetry::Core.root}/app/components" if defined?(Lookbook)
50
+ end
51
+
52
+ initializer "poetry_core.assets", before: "propshaft.set_manifest" do |_app|
53
+ # Add JavaScript to asset paths for Propshaft
54
+ if Rails.application.config.respond_to?(:assets)
55
+ Rails.application.config.assets.paths << Poetry::Core.root.join("app/javascript")
56
+ end
57
+ end
58
+
59
+ # The importmap-first JS channel: merge poetry's pins into the
60
+ # host's importmap so `import ... from "@poetry/controllers"` works
61
+ # with zero build. Guarded - bundler hosts (esbuild/Vite) use the npm
62
+ # channel instead and never load importmap-rails.
63
+ initializer "poetry_core.importmap", before: "importmap" do |app|
64
+ if app.config.respond_to?(:importmap)
65
+ app.config.importmap.paths << Poetry::Core.root.join("config/importmap.rb")
66
+ app.config.importmap.cache_sweepers << Poetry::Core.root.join("app/javascript")
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Poetry::Core module provides extensions and enhancements for Rails views and components.
4
+ module Poetry
5
+ module Core
6
+ # Base error class for all Poetry::Core-related errors.
7
+ #
8
+ # All custom errors in the Poetry::Core module inherit from this class, which in turn
9
+ # inherits from Ruby's StandardError. This provides a common ancestor for
10
+ # rescuing all Poetry::Core-specific exceptions.
11
+ #
12
+ # @example Rescuing all Poetry::Core errors
13
+ # begin
14
+ # # Poetry::Core operations
15
+ # rescue Poetry::Core::Error => e
16
+ # Rails.logger.error("Poetry::Core error: #{e.message}")
17
+ # end
18
+ class Error < StandardError; end
19
+
20
+ # Raised by an icon set's #fetch for a name it cannot serve -
21
+ # malformed, or simply not in the set. This is the public rescue
22
+ # point of the icon system: the Icon component's missing-icon policy
23
+ # rescues exactly this class, so a custom set registered via
24
+ # {Poetry::Core::Icons.register} must raise it too (that is the set
25
+ # contract).
26
+ #
27
+ # @example Custom handling around a dynamic icon name
28
+ # begin
29
+ # Poetry::Core::Icons.set.fetch(user_preference)
30
+ # rescue Poetry::Core::IconNotFound => e
31
+ # logger.info("missing icon #{e.name.inspect}") # e.suggestion may name the fix
32
+ # end
33
+ class IconNotFound < Error
34
+ # @return [Symbol, String] the requested icon name, as given
35
+ attr_reader :name
36
+
37
+ # @return [String, nil] the closest valid name, when one exists
38
+ attr_reader :suggestion
39
+
40
+ # Carries the requested name (and the did-you-mean fix, when one
41
+ # exists) alongside the message.
42
+ #
43
+ # @param message [String] the full error message
44
+ # @param name [Symbol, String] the requested icon name
45
+ # @param suggestion [String, nil] the closest valid name, when known
46
+ def initialize(message, name:, suggestion: nil)
47
+ super(message)
48
+ @name = name
49
+ @suggestion = suggestion
50
+ end
51
+ end
52
+ end
53
+ end