markawesome 0.16.0 → 0.17.0

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: eee224c1babae361baa15c8c0f0a65bebf839d9debc599bf8e4a43bb3752bdb4
4
- data.tar.gz: 9d8d78ec8aaf682bf252c634a220fa7cb9283ec15dc70c280d69967ed9f2945b
3
+ metadata.gz: a8753b6516f4a04d147b50bd980b9dd2040dc2cba63a87746121b4e25bb8529e
4
+ data.tar.gz: 606d078978520bf6f53c96d4d91f2f1bb66cbdbc85e1d4236d2d3ca18f222c35
5
5
  SHA512:
6
- metadata.gz: edb36c15048371cf37c3d860d214faacc61158b9a497f09293ade0fb152b02bcb0c7a37ed1db0725939f1bea4d5159aad9bf3452456120ed5c5250c55dd61f4b
7
- data.tar.gz: 112fbcbb947dc90348bc2c187b0ee3a8379fba4bff06e8da0ee3f8b550e9307933333795e245a524b41478b41fc75875f33a62f1b63263a13c5dabd16a37475f
6
+ metadata.gz: a647d34d02688da9ce8b7a5511f8544123c4943c9f108f66598a9da8246bf6c7130d2138cde68392efc8b7603be42679e88843414847759c69d48d1cfc8297ca
7
+ data.tar.gz: fc0f43062d5ca3a7a2a12d80c8a77cbfc33a856270b1d02dbb5d9203200c591b6b74e768ea3ccc8c48b1aadb607bf9228dd5602acd0bcc81dbc8f8d865a920da
data/CHANGELOG.md CHANGED
@@ -4,6 +4,31 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ## [0.17.0] - 2026-06-26
10
+
11
+ ### Added
12
+
13
+ - New `VideoTransformer` producing Web Awesome's two media components — `<wa-video>` (a single embedded video with custom controls) and `<wa-video-playlist>` (a playlist wrapping multiple `<wa-video>` children). Both are Web Awesome **Pro** (experimental); the markup emitted is declarative and static-site-safe.
14
+ - **Single video** (primary): a `;;;<tokens>` fence, body, closing `;;;`. **Playlist** (primary): a `;;;;;;<tokens>` container wrapping bare `;;;` items (mirroring the carousel `~~~`/`~~~~~~` structure — the bare `;;;` item open keeps the closing `;;;;;;` from being mis-read as an item). Block alternatives: `:::wa-video <tokens>` and `:::wa-video-playlist <tokens>`. Runs in the pipeline immediately after `ComparisonTransformer`.
15
+ - **Body**: the first markdown link `[text](url)` supplies `title`/`src`; the first markdown image `![alt](url)` supplies `poster` (a negative lookbehind keeps the image's `[…]()` from being taken as the link). A block with no link (no `src`) is left untransformed.
16
+ - **Tokens**: `controls:none|standard|full` and `preload:auto|metadata|none` (enum-validated `key:value`; invalid values dropped) plus the boolean flags `autoplay`, `autoplay-muted`, `autoplay-on-visible`, `loop`, `muted` (whole-token matched, so `autoplay-muted` never triggers `autoplay`). The playlist's `controls` preset is forwarded to the container only — children omit it. Deterministic emission order: `src`, `poster`, `title`, `controls`, `preload`, then `autoplay`, `autoplay-muted`, `autoplay-on-visible`, `loop`, `muted`; `src`/`poster`/`title` are HTML-escaped.
17
+ - **Plain-markdown degradation** (`render_as_markdown`, used for `.md` endpoints / llms.txt): a single video degrades to `[title](src)`; a playlist degrades to a bulleted list of `- [title](src)`.
18
+ - Captions (`<track>`) and multi-format `<source>` children are documented v1 follow-ups — a single `src` attribute suffices today.
19
+ - New `DateTransformer` producing Web Awesome's two declarative timestamp components — `<wa-format-date>` (an absolute, locale-formatted date such as "June 26, 2026") and `<wa-relative-time>` ("3 days ago", optionally live-ticking). The date value is baked into the markup at build time; both components are pure declarative wrappers over the browser's `Intl.DateTimeFormat` / `Intl.RelativeTimeFormat`, with no data fetching — ideal for blog post dates, changelog stamps, and "last updated".
20
+ - **Inline syntax** (primary): `[[[ <date> <tokens> ]]]` — triple square brackets, single-line, transformed before Kramdown (runs right after `TooltipTransformer`). `[[[` collides with no other delimiter and survives Kramdown + Jekyll/Liquid.
21
+ - **Block alternative**: `:::wa-format-date <date> <tokens>` / `:::wa-relative-time <date> <tokens>` with an empty body closed by `:::`. The selector name chooses the mode directly.
22
+ - **Mode**: absolute (`<wa-format-date>`) is the default; a bare `relative` token in the inline form switches to `<wa-relative-time>`.
23
+ - **Date token**: the token matching ISO 8601 `YYYY-MM-DD` (optionally `THH:MM[:SS][.s][Z|±HH:MM]`) is passed verbatim (escaped) into `date="…"`. If omitted, `date` is dropped and the component shows the viewer's **current** time (runtime-now). Datetimes use the `T` separator (a space would break tokenization).
24
+ - **format-date formatting**: `style:short|medium|long|full` and `time:short|medium|long|full` presets expand to Web Awesome's granular attributes, with granular overrides (`weekday`, `era`, `year`, `month`, `day`, `hour`, `minute`, `second`, `hour-format`, `time-zone-name`, `time-zone`, `lang`/`locale` → `lang`) winning per key (rightmost-wins). Enum values are validated; invalid values and unknown tokens are silently dropped. A bare date with no style/time/granular field defaults to `style:long`. Deterministic emission order: `date weekday era year month day hour minute second time-zone-name time-zone hour-format lang`.
25
+ - **relative-time formatting**: `format` (`long|short|narrow`, default `long` omitted), `numeric` (`auto|always`, default `auto` omitted), `sync` (boolean live-update flag), and `lang`/`locale` → `lang`. Date/style tokens are ignored in relative mode. Deterministic emission order: `date format numeric sync lang`.
26
+ - **Plain-markdown degradation** (`render_as_markdown`, used for `.md` endpoints / llms.txt): each timestamp degrades to its raw date string (empty for a runtime-now timestamp), since plain text has no locale formatting.
27
+ - **Static-site caveat**: like `<wa-icon>`, both timestamp components render generated text into shadow DOM with no light-DOM fallback — with Web Awesome's JS disabled they show nothing. This matches our existing generated-content model (documented alongside the `<wa-tag with-remove>` caveat); no fallback text is emitted.
28
+ - `PopoverTransformer` and `TooltipTransformer` now accept **all twelve** Web Awesome placements — the four primary (`top`, `bottom`, `left`, `right`) plus the eight aligned variants (`top-start`, `top-end`, `right-start`, `right-end`, `bottom-start`, `bottom-end`, `left-start`, `left-end`) — matching `<wa-popover>`/`<wa-tooltip>`'s full `placement` surface. `AttributeParser` matches whole tokens, so `bottom-start` resolves without colliding with `bottom`.
29
+ - `PopoverTransformer` and `TooltipTransformer` gain a `skidding:N` token, mirroring `distance:N`. It emits `<wa-popover>`/`<wa-tooltip>`'s `skidding` attribute — the offset **along** the target (whereas `distance` is the offset **away** from it). Negative values are allowed (`skidding:-4`). Emission order is deterministic: `placement`, `without-arrow`, `distance`, `skidding` (popover) and `placement`, `distance`, `skidding` (tooltip).
30
+ - `TabsTransformer` supports a per-tab `disabled` flag as a **leading token** on the `+++ ` item header (e.g. `+++ disabled Coming soon`), mirroring the accordion item flags. It emits `<wa-tab disabled>` (the tab renders but cannot be selected) and strips the flag from the label; a non-leading occurrence of the word is left untouched. The `render_as_markdown` degradation strips the flag too (`### Coming soon`). Non-disabled tabs are byte-identical to before.
31
+
7
32
  ## [0.16.0] - 2026-06-25
8
33
 
9
34
  ### Added
@@ -46,12 +46,14 @@ module Markawesome
46
46
  layout
47
47
  popover
48
48
  tooltip
49
+ date
49
50
  badge
50
51
  button
51
52
  callout
52
53
  card
53
54
  carousel
54
55
  comparison
56
+ video
55
57
  copy_button
56
58
  details
57
59
  image_dialog
@@ -66,12 +68,14 @@ module Markawesome
66
68
  layout: LayoutTransformer,
67
69
  popover: PopoverTransformer,
68
70
  tooltip: TooltipTransformer,
71
+ date: DateTransformer,
69
72
  badge: BadgeTransformer,
70
73
  button: ButtonTransformer,
71
74
  callout: CalloutTransformer,
72
75
  card: CardTransformer,
73
76
  carousel: CarouselTransformer,
74
77
  comparison: ComparisonTransformer,
78
+ video: VideoTransformer,
75
79
  copy_button: CopyButtonTransformer,
76
80
  details: DetailsTransformer,
77
81
  image_dialog: ImageDialogTransformer,
@@ -13,12 +13,14 @@ module Markawesome
13
13
  content = LayoutTransformer.transform(content)
14
14
  content = PopoverTransformer.transform(content)
15
15
  content = TooltipTransformer.transform(content)
16
+ content = DateTransformer.transform(content)
16
17
  content = BadgeTransformer.transform(content)
17
18
  content = ButtonTransformer.transform(content)
18
19
  content = CalloutTransformer.transform(content)
19
20
  content = CardTransformer.transform(content)
20
21
  content = CarouselTransformer.transform(content)
21
22
  content = ComparisonTransformer.transform(content)
23
+ content = VideoTransformer.transform(content)
22
24
  content = CopyButtonTransformer.transform(content)
23
25
  content = DetailsTransformer.transform(content)
24
26
 
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_transformer'
4
+
5
+ module Markawesome
6
+ # Transforms declarative timestamp syntax into Web Awesome's two timestamp
7
+ # components:
8
+ # <wa-format-date> — an absolute, locale-formatted date ("June 26, 2026")
9
+ # <wa-relative-time> — a relative phrase ("3 days ago"), optionally ticking
10
+ #
11
+ # These are pure declarative wrappers over the browser's Intl.DateTimeFormat /
12
+ # Intl.RelativeTimeFormat: the date value is baked into the markup at build
13
+ # time, with no data fetching. Great for blog dates, changelog stamps, and
14
+ # "last updated".
15
+ #
16
+ # Inline (primary): [[[ <date> <tokens> ]]]
17
+ # Block (alternative): :::wa-format-date <date> <tokens> / :::wa-relative-time …
18
+ # followed by a closing ::: (empty body)
19
+ #
20
+ # Mode: absolute (<wa-format-date>) is the default; a bare `relative` token in
21
+ # the inline form switches to <wa-relative-time>. The block selector name
22
+ # chooses the mode directly.
23
+ #
24
+ # Static-site caveat: both components render generated text into shadow DOM
25
+ # with no light-DOM fallback — with Web Awesome's JS disabled they show
26
+ # nothing. This is identical to <wa-icon> and the other generated-content
27
+ # components we already emit, so it is consistent with our model.
28
+ class DateTransformer < BaseTransformer
29
+ # Inline: content excludes `]`, non-greedy, multiple-per-line, single-line.
30
+ INLINE_REGEX = /\[\[\[[ \t]*([^\]\r\n]+?)[ \t]*\]\]\]/
31
+ # Block: selector name picks the mode; an empty body, closed by `:::`.
32
+ ALTERNATIVE_REGEX = /^:::wa-(format-date|relative-time)[ \t]*([^\n]*)\n:::$/
33
+
34
+ # A token is the date when it is an ISO 8601 date or datetime (datetimes use
35
+ # the `T` separator — a space would break whitespace tokenization).
36
+ DATE_TOKEN_REGEX =
37
+ /\A\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?\z/
38
+
39
+ # style:/time: presets expand to Web Awesome's granular date/time attributes.
40
+ STYLE_PRESETS = {
41
+ 'short' => { 'month' => 'numeric', 'day' => 'numeric', 'year' => '2-digit' },
42
+ 'medium' => { 'month' => 'short', 'day' => 'numeric', 'year' => 'numeric' },
43
+ 'long' => { 'month' => 'long', 'day' => 'numeric', 'year' => 'numeric' },
44
+ 'full' => { 'weekday' => 'long', 'month' => 'long', 'day' => 'numeric', 'year' => 'numeric' }
45
+ }.freeze
46
+
47
+ TIME_PRESETS = {
48
+ 'short' => { 'hour' => 'numeric', 'minute' => 'numeric' },
49
+ 'medium' => { 'hour' => 'numeric', 'minute' => 'numeric', 'second' => 'numeric' },
50
+ 'long' => { 'hour' => 'numeric', 'minute' => 'numeric', 'second' => 'numeric', 'time-zone-name' => 'short' },
51
+ 'full' => { 'hour' => 'numeric', 'minute' => 'numeric', 'second' => 'numeric', 'time-zone-name' => 'long' }
52
+ }.freeze
53
+
54
+ # Granular key:value tokens that pass through to the same-named WA attribute,
55
+ # validated against an allowed enum (invalid values dropped).
56
+ GRANULAR_ENUMS = {
57
+ 'weekday' => %w[narrow short long],
58
+ 'era' => %w[narrow short long],
59
+ 'year' => %w[numeric 2-digit],
60
+ 'month' => %w[numeric 2-digit narrow short long],
61
+ 'day' => %w[numeric 2-digit],
62
+ 'hour' => %w[numeric 2-digit],
63
+ 'minute' => %w[numeric 2-digit],
64
+ 'second' => %w[numeric 2-digit],
65
+ 'hour-format' => %w[auto 12 24],
66
+ 'time-zone-name' => %w[short long]
67
+ }.freeze
68
+
69
+ # Granular keys that count as an explicit date/time field — their presence
70
+ # (or a style:/time: preset) suppresses the style:long default.
71
+ CONTENT_FIELDS = %w[weekday era year month day hour minute second time-zone-name].freeze
72
+
73
+ # Deterministic emission order (required for byte-for-byte parity).
74
+ FORMAT_DATE_ORDER = %w[date weekday era year month day hour minute second
75
+ time-zone-name time-zone hour-format lang].freeze
76
+
77
+ RELATIVE_FORMATS = %w[long short narrow].freeze
78
+ RELATIVE_NUMERICS = %w[auto always].freeze
79
+
80
+ def self.transform(content)
81
+ patterns = [
82
+ { regex: INLINE_REGEX, block: proc { |_m, md| render_tokens(md[1], nil) } },
83
+ { regex: ALTERNATIVE_REGEX,
84
+ block: proc { |_m, md| render_tokens(md[2], md[1] == 'relative-time' ? :relative : :absolute) } }
85
+ ]
86
+ apply_multiple_patterns(content, patterns)
87
+ end
88
+
89
+ # Plain-markdown degradation: there is no locale formatting in plain text,
90
+ # so each timestamp degrades to its raw date string (empty when omitted).
91
+ def self.render_as_markdown(content, _options = {})
92
+ patterns = [
93
+ { regex: INLINE_REGEX, block: proc { |_m, md| extract_date(md[1]) } },
94
+ { regex: ALTERNATIVE_REGEX, block: proc { |_m, md| extract_date(md[2]) } }
95
+ ]
96
+ apply_multiple_patterns(content, patterns)
97
+ end
98
+
99
+ class << self
100
+ private
101
+
102
+ def render_tokens(token_string, mode_override)
103
+ tokens = token_string.to_s.strip.split(/\s+/)
104
+ date, tokens = split_date(tokens)
105
+ mode = mode_override || (tokens.include?('relative') ? :relative : :absolute)
106
+
107
+ mode == :relative ? build_relative_time(date, tokens) : build_format_date(date, tokens)
108
+ end
109
+
110
+ # Pull the first date/datetime token out, leaving the option tokens.
111
+ def split_date(tokens)
112
+ index = tokens.index { |token| token.match?(DATE_TOKEN_REGEX) }
113
+ return [nil, tokens] unless index
114
+
115
+ [tokens[index], tokens[0...index] + tokens[(index + 1)..]]
116
+ end
117
+
118
+ def extract_date(token_string)
119
+ tokens = token_string.to_s.strip.split(/\s+/)
120
+ tokens.find { |token| token.match?(DATE_TOKEN_REGEX) }.to_s
121
+ end
122
+
123
+ def build_format_date(date, tokens)
124
+ attrs = {}
125
+ apply_presets(tokens, attrs)
126
+ apply_granular(tokens, attrs)
127
+ # A bare date (no style/time/granular field) defaults to style:long.
128
+ attrs.merge!(STYLE_PRESETS['long']) if (attrs.keys & CONTENT_FIELDS).empty?
129
+ attrs['date'] = date if date
130
+
131
+ parts = FORMAT_DATE_ORDER.filter_map do |key|
132
+ "#{key}=\"#{escape_html(attrs[key])}\"" if attrs.key?(key)
133
+ end
134
+ build_element('wa-format-date', parts)
135
+ end
136
+
137
+ # Apply the rightmost valid style: and time: presets.
138
+ def apply_presets(tokens, attrs)
139
+ style = nil
140
+ time = nil
141
+ tokens.each do |token|
142
+ if (m = token.match(/\Astyle:(.+)\z/)) && STYLE_PRESETS.key?(m[1])
143
+ style = m[1]
144
+ elsif (m = token.match(/\Atime:(.+)\z/)) && TIME_PRESETS.key?(m[1])
145
+ time = m[1]
146
+ end
147
+ end
148
+ attrs.merge!(STYLE_PRESETS[style]) if style
149
+ attrs.merge!(TIME_PRESETS[time]) if time
150
+ end
151
+
152
+ # Apply granular enum keys (override presets) and free-string modifiers
153
+ # (time-zone, lang/locale). Later tokens win.
154
+ def apply_granular(tokens, attrs)
155
+ tokens.each do |token|
156
+ next unless (m = token.match(/\A([a-z-]+):(.+)\z/))
157
+
158
+ key = m[1]
159
+ value = m[2]
160
+ if GRANULAR_ENUMS[key]&.include?(value)
161
+ attrs[key] = value
162
+ elsif key == 'time-zone'
163
+ attrs['time-zone'] = value
164
+ elsif %w[lang locale].include?(key)
165
+ attrs['lang'] = value
166
+ end
167
+ end
168
+ end
169
+
170
+ def build_relative_time(date, tokens)
171
+ opts = parse_relative_options(tokens)
172
+ parts = []
173
+ parts << "date=\"#{escape_html(date)}\"" if date
174
+ parts << "format=\"#{opts[:format]}\"" if opts[:format] && opts[:format] != 'long'
175
+ parts << "numeric=\"#{opts[:numeric]}\"" if opts[:numeric] && opts[:numeric] != 'auto'
176
+ parts << 'sync' if opts[:sync]
177
+ parts << "lang=\"#{escape_html(opts[:lang])}\"" if opts[:lang]
178
+ build_element('wa-relative-time', parts)
179
+ end
180
+
181
+ def parse_relative_options(tokens)
182
+ opts = { format: nil, numeric: nil, sync: false, lang: nil }
183
+ tokens.each do |token|
184
+ if token == 'sync'
185
+ opts[:sync] = true
186
+ elsif (m = token.match(/\Aformat:(.+)\z/)) && RELATIVE_FORMATS.include?(m[1])
187
+ opts[:format] = m[1]
188
+ elsif (m = token.match(/\Anumeric:(.+)\z/)) && RELATIVE_NUMERICS.include?(m[1])
189
+ opts[:numeric] = m[1]
190
+ elsif (m = token.match(/\A(?:lang|locale):(.+)\z/))
191
+ opts[:lang] = m[1]
192
+ end
193
+ end
194
+ opts
195
+ end
196
+
197
+ def build_element(tag, parts)
198
+ return "<#{tag}></#{tag}>" if parts.empty?
199
+
200
+ "<#{tag} #{parts.join(' ')}></#{tag}>"
201
+ end
202
+
203
+ def escape_html(text)
204
+ text.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;')
205
+ .gsub('"', '&quot;').gsub("'", '&#39;')
206
+ end
207
+ end
208
+ end
209
+ end
@@ -11,12 +11,15 @@ module Markawesome
11
11
  # Inline syntax: &&&params? trigger text >>> popover content&&&
12
12
  #
13
13
  # Params: space-separated tokens (order doesn't matter)
14
- # Placement: top (default), bottom, left, right
14
+ # Placement: top (default), bottom, left, right, plus the eight aligned
15
+ # variants (top-start, top-end, right-start, …) — the full wa-popover surface
15
16
  # Flags: without-arrow
16
- # Distance: distance:N (e.g., distance:10)
17
+ # Distance: distance:N (e.g., distance:10) — offset away from the target
18
+ # Skidding: skidding:N (e.g., skidding:12, skidding:-4) — offset along the target
17
19
  class PopoverTransformer < BaseTransformer
18
20
  POPOVER_ATTRIBUTES = {
19
- placement: %w[top bottom left right],
21
+ placement: %w[top top-start top-end right right-start right-end
22
+ bottom bottom-start bottom-end left left-start left-end],
20
23
  without_arrow: %w[without-arrow],
21
24
  trigger_style: %w[link]
22
25
  }.freeze
@@ -42,13 +45,13 @@ module Markawesome
42
45
  popover_content = matchdata[2].strip
43
46
 
44
47
  params_string, trigger_text = parse_inline_trigger_and_params(combined)
45
- placement, without_arrow, distance, _link_style = parse_parameters(params_string)
48
+ placement, without_arrow, distance, _link_style, skidding = parse_parameters(params_string)
46
49
 
47
50
  popover_id = generate_popover_id(trigger_text, popover_content, seen_ids)
48
51
 
49
52
  build_inline_popover_html(popover_id, trigger_text, popover_content,
50
53
  { placement: placement, without_arrow: without_arrow,
51
- distance: distance })
54
+ distance: distance, skidding: skidding })
52
55
  end
53
56
  }
54
57
 
@@ -57,7 +60,7 @@ module Markawesome
57
60
  trigger_text = trigger_text.strip
58
61
  popover_content = popover_content.strip
59
62
 
60
- placement, without_arrow, distance, link_style = parse_parameters(params_string)
63
+ placement, without_arrow, distance, link_style, skidding = parse_parameters(params_string)
61
64
 
62
65
  popover_id = generate_popover_id(trigger_text, popover_content, seen_ids)
63
66
 
@@ -65,7 +68,7 @@ module Markawesome
65
68
 
66
69
  build_popover_html(popover_id, trigger_text, content_html,
67
70
  { placement: placement, without_arrow: without_arrow,
68
- distance: distance, link_style: link_style })
71
+ distance: distance, link_style: link_style, skidding: skidding })
69
72
  end
70
73
 
71
74
  # Inline patterns first to avoid conflicts with block patterns
@@ -106,19 +109,21 @@ module Markawesome
106
109
  private
107
110
 
108
111
  def parse_parameters(params_string)
109
- return ['top', false, nil, false] if params_string.nil? || params_string.strip.empty?
112
+ return ['top', false, nil, false, nil] if params_string.nil? || params_string.strip.empty?
110
113
 
111
114
  attributes = AttributeParser.parse(params_string, POPOVER_ATTRIBUTES)
112
115
  placement = attributes[:placement] || 'top'
113
116
  without_arrow = attributes[:without_arrow] == 'without-arrow'
114
117
  link_style = attributes[:trigger_style] == 'link'
115
118
 
116
- # Look for distance:N parameter
119
+ # Look for distance:N and skidding:N parameters (skidding may be negative)
117
120
  tokens = params_string.strip.split(/\s+/)
118
121
  distance_token = tokens.find { |token| token.match?(/^distance:\d+$/) }
119
122
  distance = distance_token&.sub('distance:', '')
123
+ skidding_token = tokens.find { |token| token.match?(/^skidding:-?\d+$/) }
124
+ skidding = skidding_token&.sub('skidding:', '')
120
125
 
121
- [placement, without_arrow, distance, link_style]
126
+ [placement, without_arrow, distance, link_style, skidding]
122
127
  end
123
128
 
124
129
  def generate_popover_id(trigger_text, content, seen_ids)
@@ -156,42 +161,36 @@ module Markawesome
156
161
 
157
162
  def popover_param?(token)
158
163
  POPOVER_ATTRIBUTES.any? { |_attr, values| values.include?(token) } ||
159
- token.match?(/^distance:\d+$/)
164
+ token.match?(/^distance:\d+$/) ||
165
+ token.match?(/^skidding:-?\d+$/)
166
+ end
167
+
168
+ # Build the <wa-popover> attribute list. Emission order is fixed:
169
+ # for, placement, without-arrow, distance, skidding.
170
+ def popover_attributes(popover_id, options)
171
+ attrs = ["for='#{popover_id}'", "placement='#{options[:placement]}'"]
172
+ attrs << 'without-arrow' if options[:without_arrow]
173
+ attrs << "distance='#{options[:distance]}'" if options[:distance]
174
+ attrs << "skidding='#{options[:skidding]}'" if options[:skidding]
175
+ attrs
160
176
  end
161
177
 
162
178
  def build_inline_popover_html(popover_id, trigger_text, content_text, options)
163
179
  trigger_content = escape_html(trigger_text)
164
- content_escaped = escape_html(content_text)
165
- content_escaped = content_escaped.gsub('\n', '<br>')
166
-
167
- popover_attrs = ["for='#{popover_id}'"]
168
- popover_attrs << "placement='#{options[:placement]}'"
169
- popover_attrs << 'without-arrow' if options[:without_arrow]
170
- popover_attrs << "distance='#{options[:distance]}'" if options[:distance]
180
+ content_escaped = escape_html(content_text).gsub('\n', '<br>')
171
181
 
182
+ attrs = popover_attributes(popover_id, options)
172
183
  trigger = build_trigger(popover_id, trigger_content, true)
173
184
 
174
- "#{trigger}<wa-popover #{popover_attrs.join(' ')}>#{content_escaped}</wa-popover>"
185
+ "#{trigger}<wa-popover #{attrs.join(' ')}>#{content_escaped}</wa-popover>"
175
186
  end
176
187
 
177
188
  def build_popover_html(popover_id, trigger_text, content_html, options)
178
- # Escape trigger text for security
179
189
  trigger_content = escape_html(trigger_text)
180
-
181
- # Build popover attributes
182
- popover_attrs = ["for='#{popover_id}'"]
183
- popover_attrs << "placement='#{options[:placement]}'"
184
- popover_attrs << 'without-arrow' if options[:without_arrow]
185
- popover_attrs << "distance='#{options[:distance]}'" if options[:distance]
186
-
190
+ attrs = popover_attributes(popover_id, options)
187
191
  trigger = build_trigger(popover_id, trigger_content, options[:link_style])
188
192
 
189
- html = []
190
- html << trigger
191
- html << "<wa-popover #{popover_attrs.join(' ')}>"
192
- html << content_html
193
- html << '</wa-popover>'
194
- html.join("\n")
193
+ [trigger, "<wa-popover #{attrs.join(' ')}>", content_html, '</wa-popover>'].join("\n")
195
194
  end
196
195
 
197
196
  def build_trigger(popover_id, trigger_content, link_style)
@@ -12,6 +12,8 @@ module Markawesome
12
12
  # - activation: auto (default), manual
13
13
  # - active: panel name to show initially (e.g., "general", "tab-2")
14
14
  # - no-scroll-controls: disables scroll arrows
15
+ # Per-tab flag (leading token on the `+++ ` item header, mirroring accordion):
16
+ # - disabled: this tab renders but cannot be selected (e.g., "+++ disabled Coming soon")
15
17
  class TabsTransformer < BaseTransformer
16
18
  COMPONENT_ATTRIBUTES = {
17
19
  placement: %w[top bottom start end],
@@ -66,7 +68,8 @@ module Markawesome
66
68
  transform_proc = proc do |_params_string, tabs_block, _third|
67
69
  tab_contents = tabs_block.scan(/^\+\+\+ ([^\n]+)\n(.*?)\n\+\+\+/m)
68
70
  tab_contents.map do |title, panel_content|
69
- "### #{title.strip}\n\n#{panel_content.strip}"
71
+ label, = parse_tab_header(title)
72
+ "### #{label}\n\n#{panel_content.strip}"
70
73
  end.join("\n\n")
71
74
  end
72
75
 
@@ -85,7 +88,11 @@ module Markawesome
85
88
 
86
89
  tab_contents.each_with_index do |(title, panel_content), index|
87
90
  tab_id = "tab-#{index + 1}"
88
- tabs << "<wa-tab panel=\"#{tab_id}\">#{title.strip}</wa-tab>"
91
+ label, disabled = parse_tab_header(title)
92
+
93
+ tab_attrs = ["panel=\"#{tab_id}\""]
94
+ tab_attrs << 'disabled' if disabled
95
+ tabs << "<wa-tab #{tab_attrs.join(' ')}>#{label}</wa-tab>"
89
96
 
90
97
  panel_html = markdown_to_html(panel_content.strip)
91
98
  tab_panels << "<wa-tab-panel name=\"#{tab_id}\">#{panel_html}</wa-tab-panel>"
@@ -93,6 +100,17 @@ module Markawesome
93
100
 
94
101
  [tabs, tab_panels]
95
102
  end
103
+
104
+ # Parse a tab item header. A leading `disabled` token (case-sensitive,
105
+ # exactly `disabled` or `disabled `-prefixed) flags the tab as disabled and
106
+ # is stripped from the label; otherwise the label is the stripped title,
107
+ # unchanged. Mirrors accordion's leading item flags.
108
+ def parse_tab_header(title)
109
+ stripped = title.to_s.strip
110
+ return [stripped, false] unless stripped == 'disabled' || stripped.start_with?('disabled ')
111
+
112
+ [stripped.sub(/\Adisabled\s*/, ''), true]
113
+ end
96
114
  end
97
115
  end
98
116
  end
@@ -13,15 +13,18 @@ module Markawesome
13
13
  # Alternative block syntax: :::wa-tooltip params\nanchor\n>>>\ntip\n:::
14
14
  #
15
15
  # Params: space-separated tokens (order doesn't matter)
16
- # Placement: top (default), bottom, left, right
17
- # Distance: distance:N (e.g., distance:10)
16
+ # Placement: top (default), bottom, left, right, plus the eight aligned
17
+ # variants (top-start, top-end, right-start, ) — the full wa-tooltip surface
18
+ # Distance: distance:N (e.g., distance:10) — offset away from the target
19
+ # Skidding: skidding:N (e.g., skidding:12, skidding:-4) — offset along the target
18
20
  #
19
21
  # Tip content is plain text (HTML-escaped), with literal `\n` rendered as
20
22
  # <br> — the same surface as the popover's inline form. Tooltips hold brief
21
23
  # text, so there is no markdown body.
22
24
  class TooltipTransformer < BaseTransformer
23
25
  TOOLTIP_ATTRIBUTES = {
24
- placement: %w[top bottom left right]
26
+ placement: %w[top top-start top-end right right-start right-end
27
+ bottom bottom-start bottom-end left left-start left-end]
25
28
  }.freeze
26
29
 
27
30
  # Inline regex (single-line, no newlines allowed): capture 1 = params+anchor,
@@ -43,12 +46,12 @@ module Markawesome
43
46
  tip_text = matchdata[2].strip
44
47
 
45
48
  params_string, anchor_text = parse_inline_anchor_and_params(combined)
46
- placement, distance = parse_parameters(params_string)
49
+ placement, distance, skidding = parse_parameters(params_string)
47
50
 
48
51
  tooltip_id = generate_tooltip_id(anchor_text, tip_text, seen_ids)
49
52
 
50
53
  build_tooltip_html(tooltip_id, anchor_text, tip_text,
51
- { placement: placement, distance: distance })
54
+ { placement: placement, distance: distance, skidding: skidding })
52
55
  end
53
56
  }
54
57
 
@@ -59,12 +62,12 @@ module Markawesome
59
62
  anchor_text = matchdata[2].strip
60
63
  tip_text = matchdata[3].strip
61
64
 
62
- placement, distance = parse_parameters(params_string)
65
+ placement, distance, skidding = parse_parameters(params_string)
63
66
 
64
67
  tooltip_id = generate_tooltip_id(anchor_text, tip_text, seen_ids)
65
68
 
66
69
  build_tooltip_html(tooltip_id, anchor_text, tip_text,
67
- { placement: placement, distance: distance })
70
+ { placement: placement, distance: distance, skidding: skidding })
68
71
  end
69
72
  }
70
73
 
@@ -99,17 +102,19 @@ module Markawesome
99
102
  private
100
103
 
101
104
  def parse_parameters(params_string)
102
- return ['top', nil] if params_string.nil? || params_string.strip.empty?
105
+ return ['top', nil, nil] if params_string.nil? || params_string.strip.empty?
103
106
 
104
107
  attributes = AttributeParser.parse(params_string, TOOLTIP_ATTRIBUTES)
105
108
  placement = attributes[:placement] || 'top'
106
109
 
107
- # Look for distance:N parameter (rightmost-wins)
110
+ # Look for distance:N / skidding:N parameters (rightmost-wins; skidding may be negative)
108
111
  tokens = params_string.strip.split(/\s+/)
109
112
  distance_token = tokens.reverse.find { |token| token.match?(/^distance:\d+$/) }
110
113
  distance = distance_token&.sub('distance:', '')
114
+ skidding_token = tokens.reverse.find { |token| token.match?(/^skidding:-?\d+$/) }
115
+ skidding = skidding_token&.sub('skidding:', '')
111
116
 
112
- [placement, distance]
117
+ [placement, distance, skidding]
113
118
  end
114
119
 
115
120
  def generate_tooltip_id(anchor_text, tip_text, seen_ids)
@@ -147,7 +152,8 @@ module Markawesome
147
152
 
148
153
  def tooltip_param?(token)
149
154
  TOOLTIP_ATTRIBUTES.any? { |_attr, values| values.include?(token) } ||
150
- token.match?(/^distance:\d+$/)
155
+ token.match?(/^distance:\d+$/) ||
156
+ token.match?(/^skidding:-?\d+$/)
151
157
  end
152
158
 
153
159
  def build_tooltip_html(tooltip_id, anchor_text, tip_text, options)
@@ -157,6 +163,7 @@ module Markawesome
157
163
  tooltip_attrs = ["for=\"#{tooltip_id}\""]
158
164
  tooltip_attrs << "placement=\"#{options[:placement]}\""
159
165
  tooltip_attrs << "distance=\"#{options[:distance]}\"" if options[:distance]
166
+ tooltip_attrs << "skidding=\"#{options[:skidding]}\"" if options[:skidding]
160
167
 
161
168
  anchor = build_anchor(tooltip_id, anchor_content)
162
169
 
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_transformer'
4
+ require_relative '../attribute_parser'
5
+
6
+ module Markawesome
7
+ # Transforms video syntax into Web Awesome's two media components:
8
+ # <wa-video> — a single embedded video with custom controls
9
+ # <wa-video-playlist> — a playlist wrapping multiple <wa-video> children
10
+ #
11
+ # Single video
12
+ # Primary: ;;;tokens\n[Title](src)\n![Poster](poster)\n;;;
13
+ # Alternative: :::wa-video tokens\n…\n:::
14
+ #
15
+ # Playlist (a ;;;;;; container wrapping bare ;;; items, mirroring carousel)
16
+ # Primary: ;;;;;;tokens\n;;;\n[Title](src)\n;;;\n…\n;;;;;;
17
+ # Alternative: :::wa-video-playlist tokens\n;;;\n…\n;;;\n:::
18
+ #
19
+ # Body: the first markdown link `[text](url)` supplies `title`/`src`; the first
20
+ # markdown image `![alt](url)` supplies `poster`. A block with no link (no
21
+ # `src`) is left untransformed.
22
+ #
23
+ # Tokens: `controls:none|standard|full` and `preload:auto|metadata|none`
24
+ # (enum-validated, invalid values dropped) plus the boolean flags
25
+ # `autoplay`, `autoplay-muted`, `autoplay-on-visible`, `loop`, `muted`.
26
+ # Playlist children omit `controls` (the container forwards it to each child).
27
+ class VideoTransformer < BaseTransformer
28
+ # Boolean flags, matched as whole tokens so `autoplay-muted` never triggers
29
+ # `autoplay`. Parsed via AttributeParser (rightmost-wins, order-independent).
30
+ VIDEO_FLAGS = {
31
+ autoplay: %w[autoplay],
32
+ 'autoplay-muted': %w[autoplay-muted],
33
+ 'autoplay-on-visible': %w[autoplay-on-visible],
34
+ loop: %w[loop],
35
+ muted: %w[muted]
36
+ }.freeze
37
+
38
+ # Deterministic emission order for the boolean flags.
39
+ FLAG_ORDER = %w[autoplay autoplay-muted autoplay-on-visible loop muted].freeze
40
+
41
+ CONTROLS_VALUES = %w[none standard full].freeze
42
+ PRELOAD_VALUES = %w[auto metadata none].freeze
43
+
44
+ # Playlist consumes its inner ;;; items first; the bare `;;;\n` item open
45
+ # (no params) is load-bearing — it stops the closing `;;;;;;` from being
46
+ # mis-read as another item (mirrors carousel's `~~~`/`~~~~~~` trick).
47
+ PLAYLIST_PRIMARY = /^;{6}([^\n]*)\n((?:;;;\n(?:.*?\n)?;;;\n?)+);{6}/m
48
+ PLAYLIST_ALT = /^:::wa-video-playlist\s*([^\n]*)\n(.*?)\n:::/m
49
+ # `(?!;)` keeps the single open from matching a leftover `;;;;;;` fence.
50
+ SINGLE_PRIMARY = /^;;;(?!;)([^\n]*)\n(.*?)\n^;;;$/m
51
+ SINGLE_ALT = /^:::wa-video\s*([^\n]*)\n(.*?)\n:::/m
52
+ ITEM_REGEX = /;;;\n(.*?);;;(?:\n|$)/m
53
+
54
+ # First markdown link that is not the `![…]()` of an image (negative
55
+ # lookbehind on `!`) → title + src.
56
+ LINK_REGEX = /(?<!!)\[([^\]]+)\]\(([^)]+)\)/
57
+ # First markdown image → poster.
58
+ IMAGE_REGEX = /!\[([^\]]*)\]\(([^)]+)\)/
59
+
60
+ def self.transform(content)
61
+ patterns = [
62
+ { regex: PLAYLIST_PRIMARY, block: proc { |_m, md| build_playlist(md[1], md[2]) } },
63
+ { regex: PLAYLIST_ALT, block: proc { |_m, md| build_playlist(md[1], md[2]) } },
64
+ { regex: SINGLE_PRIMARY, block: proc { |m, md| build_single(md[1], md[2]) || m } },
65
+ { regex: SINGLE_ALT, block: proc { |m, md| build_single(md[1], md[2]) || m } }
66
+ ]
67
+ apply_multiple_patterns(content, patterns)
68
+ end
69
+
70
+ def self.render_as_markdown(content, _options = {})
71
+ patterns = [
72
+ { regex: PLAYLIST_PRIMARY, block: proc { |_m, md| render_playlist_markdown(md[2]) } },
73
+ { regex: PLAYLIST_ALT, block: proc { |_m, md| render_playlist_markdown(md[2]) } },
74
+ { regex: SINGLE_PRIMARY, block: proc { |m, md| render_single_markdown(md[2]) || m } },
75
+ { regex: SINGLE_ALT, block: proc { |m, md| render_single_markdown(md[2]) || m } }
76
+ ]
77
+ apply_multiple_patterns(content, patterns)
78
+ end
79
+
80
+ class << self
81
+ private
82
+
83
+ def build_single(params, body)
84
+ build_video(params, body, suppress_controls: false)
85
+ end
86
+
87
+ def build_playlist(params, body)
88
+ controls = parse_tokens(params)[:controls]
89
+ controls_attr = controls ? " controls=\"#{controls}\"" : ''
90
+ children = extract_items(body).filter_map do |item_body|
91
+ build_video('', item_body, suppress_controls: true)
92
+ end
93
+ "<wa-video-playlist#{controls_attr}>#{children.join}</wa-video-playlist>"
94
+ end
95
+
96
+ # Returns the <wa-video> HTML, or nil when the body has no link (no `src`),
97
+ # signalling the caller to leave the block untransformed.
98
+ def build_video(params, body, suppress_controls:)
99
+ tokens = parse_tokens(params)
100
+ title, src, poster = extract_link_and_image(body)
101
+ return nil unless src
102
+
103
+ parts = ["src=\"#{escape_html(src)}\""]
104
+ parts << "poster=\"#{escape_html(poster)}\"" if poster
105
+ parts << "title=\"#{escape_html(title)}\"" if title && !title.empty?
106
+ parts << "controls=\"#{tokens[:controls]}\"" if tokens[:controls] && !suppress_controls
107
+ parts << "preload=\"#{tokens[:preload]}\"" if tokens[:preload]
108
+ FLAG_ORDER.each { |flag| parts << flag if tokens[:flags].include?(flag) }
109
+
110
+ "<wa-video #{parts.join(' ')}></wa-video>"
111
+ end
112
+
113
+ def parse_tokens(params)
114
+ result = { controls: nil, preload: nil, flags: [] }
115
+ params.to_s.strip.split(/\s+/).each do |token|
116
+ if (m = token.match(/\Acontrols:(.+)\z/)) && CONTROLS_VALUES.include?(m[1])
117
+ result[:controls] = m[1]
118
+ elsif (m = token.match(/\Apreload:(.+)\z/)) && PRELOAD_VALUES.include?(m[1])
119
+ result[:preload] = m[1]
120
+ end
121
+ end
122
+ result[:flags] = AttributeParser.parse(params, VIDEO_FLAGS).keys.map(&:to_s)
123
+ result
124
+ end
125
+
126
+ def extract_items(body)
127
+ body.scan(ITEM_REGEX).map { |match| match[0] }
128
+ end
129
+
130
+ def extract_link_and_image(body)
131
+ link = body.match(LINK_REGEX)
132
+ image = body.match(IMAGE_REGEX)
133
+ [link && link[1], link && link[2], image && image[2]]
134
+ end
135
+
136
+ def render_single_markdown(body)
137
+ title, src, = extract_link_and_image(body)
138
+ return nil unless src
139
+
140
+ "[#{title.to_s.empty? ? src : title}](#{src})"
141
+ end
142
+
143
+ def render_playlist_markdown(body)
144
+ extract_items(body).filter_map do |item_body|
145
+ title, src, = extract_link_and_image(item_body)
146
+ next unless src
147
+
148
+ "- [#{title.to_s.empty? ? src : title}](#{src})"
149
+ end.join("\n")
150
+ end
151
+
152
+ def escape_html(text)
153
+ text.to_s.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;')
154
+ .gsub('"', '&quot;').gsub("'", '&#39;')
155
+ end
156
+ end
157
+ end
158
+ end
@@ -12,6 +12,7 @@ require_relative 'transformers/card_transformer'
12
12
  require_relative 'transformers/carousel_transformer'
13
13
  require_relative 'transformers/comparison_transformer'
14
14
  require_relative 'transformers/copy_button_transformer'
15
+ require_relative 'transformers/date_transformer'
15
16
  require_relative 'transformers/details_transformer'
16
17
  require_relative 'transformers/dialog_transformer'
17
18
  require_relative 'transformers/icon_transformer'
@@ -21,3 +22,4 @@ require_relative 'transformers/popover_transformer'
21
22
  require_relative 'transformers/tabs_transformer'
22
23
  require_relative 'transformers/tag_transformer'
23
24
  require_relative 'transformers/tooltip_transformer'
25
+ require_relative 'transformers/video_transformer'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Markawesome
4
- VERSION = '0.16.0'
4
+ VERSION = '0.17.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: markawesome
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.16.0
4
+ version: 0.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Janne Waren
@@ -93,6 +93,7 @@ files:
93
93
  - lib/markawesome/transformers/carousel_transformer.rb
94
94
  - lib/markawesome/transformers/comparison_transformer.rb
95
95
  - lib/markawesome/transformers/copy_button_transformer.rb
96
+ - lib/markawesome/transformers/date_transformer.rb
96
97
  - lib/markawesome/transformers/details_transformer.rb
97
98
  - lib/markawesome/transformers/dialog_transformer.rb
98
99
  - lib/markawesome/transformers/icon_transformer.rb
@@ -102,6 +103,7 @@ files:
102
103
  - lib/markawesome/transformers/tabs_transformer.rb
103
104
  - lib/markawesome/transformers/tag_transformer.rb
104
105
  - lib/markawesome/transformers/tooltip_transformer.rb
106
+ - lib/markawesome/transformers/video_transformer.rb
105
107
  - lib/markawesome/version.rb
106
108
  - markawesome.gemspec
107
109
  homepage: https://github.com/jannewaren/markawesome