charming 0.1.3 → 0.1.4
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 +4 -4
- data/lib/charming/application.rb +19 -2
- data/lib/charming/cli.rb +3 -3
- data/lib/charming/controller/component_dispatching.rb +47 -3
- data/lib/charming/controller/focus.rb +123 -0
- data/lib/charming/controller/focus_management.rb +1 -1
- data/lib/charming/controller/rendering.rb +4 -15
- data/lib/charming/controller/session_state.rb +11 -0
- data/lib/charming/controller.rb +11 -2
- data/lib/charming/database/commands.rb +106 -0
- data/lib/charming/generators/database_installer.rb +154 -0
- data/lib/charming/generators/model_generator.rb +2 -10
- data/lib/charming/generators/name.rb +1 -1
- data/lib/charming/generators/view_generator.rb +1 -1
- data/lib/charming/presentation/components/form/field.rb +1 -1
- data/lib/charming/presentation/components/markdown.rb +7 -7
- data/lib/charming/presentation/layout/pane.rb +7 -0
- data/lib/charming/presentation/layout/rect.rb +5 -0
- data/lib/charming/presentation/layout/screen_layout.rb +7 -0
- data/lib/charming/presentation/layout/split.rb +7 -0
- data/lib/charming/presentation/markdown/render_context.rb +28 -10
- data/lib/charming/presentation/markdown/renderer.rb +264 -39
- data/lib/charming/presentation/markdown/style_config.rb +215 -0
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +3 -2
- data/lib/charming/presentation/markdown.rb +2 -2
- data/lib/charming/presentation/view.rb +7 -0
- data/lib/charming/router.rb +3 -8
- data/lib/charming/runtime.rb +2 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +2 -2
- metadata +42 -9
- data/lib/charming/database_commands.rb +0 -103
- data/lib/charming/database_installer.rb +0 -152
- data/lib/charming/focus.rb +0 -121
- data/lib/charming/presentation/markdown/block_renderers.rb +0 -118
- data/lib/charming/presentation/markdown/inline_renderers.rb +0 -66
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "fileutils"
|
|
4
|
-
|
|
5
|
-
module Charming
|
|
6
|
-
# DatabaseInstaller implements `charming db:install sqlite3`. It adds database support
|
|
7
|
-
# to an existing Charming app by creating `config/database.rb`, `app/models/application_record.rb`,
|
|
8
|
-
# `db/migrate/`, and `db/seeds.rb`, and patching the gemspec and root loader to include
|
|
9
|
-
# the new dependencies and the `app/models` autoload directory.
|
|
10
|
-
class DatabaseInstaller
|
|
11
|
-
# *database* is the adapter name (only "sqlite3" is currently supported). *out* is the
|
|
12
|
-
# status-output stream. *destination* is the app root.
|
|
13
|
-
def initialize(database, out:, destination:)
|
|
14
|
-
@database = database
|
|
15
|
-
@out = out
|
|
16
|
-
@destination = destination
|
|
17
|
-
@app_name = Generators::Name.new(app_name_from_gemspec)
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
# Performs the install: writes the database config, application record, migrate directory,
|
|
21
|
-
# seeds file, and patches the gemspec + root loader. Idempotent: existing files are
|
|
22
|
-
# reported with "exist <path>" instead of being overwritten.
|
|
23
|
-
def install
|
|
24
|
-
raise Generators::Error, "Unsupported database: #{database.inspect}" unless database == "sqlite3"
|
|
25
|
-
|
|
26
|
-
create_file("config/database.rb", database_config)
|
|
27
|
-
create_file("app/models/application_record.rb", application_record)
|
|
28
|
-
create_file("db/migrate/.keep", "")
|
|
29
|
-
create_file("db/seeds.rb", %(# frozen_string_literal: true
|
|
30
|
-
))
|
|
31
|
-
update_gemspec
|
|
32
|
-
update_root_file
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
private
|
|
36
|
-
|
|
37
|
-
# The database adapter, status stream, app destination, and derived app name.
|
|
38
|
-
attr_reader :database, :out, :destination, :app_name
|
|
39
|
-
|
|
40
|
-
# Writes *content* to *path* (relative to the app root), creating intermediate directories.
|
|
41
|
-
# Reports "exist <path>" without overwriting when the file already exists.
|
|
42
|
-
def create_file(path, content)
|
|
43
|
-
absolute_path = File.join(destination, path)
|
|
44
|
-
if File.exist?(absolute_path)
|
|
45
|
-
out.puts "exist #{path}"
|
|
46
|
-
return
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
FileUtils.mkdir_p(File.dirname(absolute_path))
|
|
50
|
-
File.write(absolute_path, content)
|
|
51
|
-
out.puts "create #{path}"
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# Patches the gemspec to include the `db` directory in the gem files glob and to add
|
|
55
|
-
# activerecord + sqlite3 dependencies.
|
|
56
|
-
def update_gemspec
|
|
57
|
-
update_file(gemspec_path) do |current|
|
|
58
|
-
updated = current.sub('Dir.glob("{app,config,exe,lib}/**/*")', 'Dir.glob("{app,config,db,exe,lib}/**/*")')
|
|
59
|
-
updated = insert_dependency(updated, "activerecord", "~> 8.1")
|
|
60
|
-
insert_dependency(updated, "sqlite3", "~> 2.0")
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
# Patches the root loader file (`lib/<app>.rb`) to require `config/database` and to push
|
|
65
|
-
# the `app/models` autoload directory. Both edits are no-ops when already applied.
|
|
66
|
-
def update_root_file
|
|
67
|
-
update_file(root_file_path) do |current|
|
|
68
|
-
updated = current
|
|
69
|
-
updated = updated.sub(%(require "zeitwerk"\n), %(require "zeitwerk"\nrequire_relative "../config/database"\n)) unless updated.include?(%(require_relative "../config/database"))
|
|
70
|
-
unless updated.include?(%[loader.push_dir(File.expand_path("../app/models", __dir__), namespace: #{app_name.class_name})])
|
|
71
|
-
updated = updated.sub(
|
|
72
|
-
%[loader.push_dir(File.expand_path("../app/state", __dir__), namespace: #{app_name.class_name})\n],
|
|
73
|
-
%[loader.push_dir(File.expand_path("../app/models", __dir__), namespace: #{app_name.class_name})\nloader.push_dir(File.expand_path("../app/state", __dir__), namespace: #{app_name.class_name})\n]
|
|
74
|
-
)
|
|
75
|
-
end
|
|
76
|
-
updated
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
# Reads *path*, yields its contents to the block, and writes the result back when it
|
|
81
|
-
# differs. Raises Generators::Error when the file is missing.
|
|
82
|
-
def update_file(path)
|
|
83
|
-
raise Generators::Error, "Missing file: #{relative_path(path)}" unless File.exist?(path)
|
|
84
|
-
|
|
85
|
-
current = File.read(path)
|
|
86
|
-
updated = yield current
|
|
87
|
-
return if updated == current
|
|
88
|
-
|
|
89
|
-
File.write(path, updated)
|
|
90
|
-
out.puts "update #{relative_path(path)}"
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
# Inserts a `spec.add_dependency "name", "version"` line after the `charming` dependency
|
|
94
|
-
# when it's not already present.
|
|
95
|
-
def insert_dependency(content, gem_name, version)
|
|
96
|
-
return content if content.include?(%(spec.add_dependency "#{gem_name}"))
|
|
97
|
-
|
|
98
|
-
dependency = %( spec.add_dependency "#{gem_name}", "#{version}")
|
|
99
|
-
content.sub(%( spec.add_dependency "charming"\n), %( spec.add_dependency "charming"\n#{dependency}\n))
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
# The contents of the new `config/database.rb` (establishes an SQLite connection to
|
|
103
|
-
# `db/development.sqlite3`).
|
|
104
|
-
def database_config
|
|
105
|
-
%(# frozen_string_literal: true
|
|
106
|
-
|
|
107
|
-
require "active_record"
|
|
108
|
-
require "fileutils"
|
|
109
|
-
|
|
110
|
-
database_path = File.expand_path("../db/development.sqlite3", __dir__)
|
|
111
|
-
FileUtils.mkdir_p(File.dirname(database_path))
|
|
112
|
-
|
|
113
|
-
ActiveRecord::Base.establish_connection(
|
|
114
|
-
adapter: "sqlite3",
|
|
115
|
-
database: database_path
|
|
116
|
-
)
|
|
117
|
-
)
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
# The contents of the new `app/models/application_record.rb` (abstract ActiveRecord base).
|
|
121
|
-
def application_record
|
|
122
|
-
%(# frozen_string_literal: true
|
|
123
|
-
|
|
124
|
-
module #{app_name.class_name}
|
|
125
|
-
class ApplicationRecord < ActiveRecord::Base
|
|
126
|
-
self.abstract_class = true
|
|
127
|
-
end
|
|
128
|
-
end
|
|
129
|
-
)
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
# Reads the app's gemspec filename to derive the app name.
|
|
133
|
-
def app_name_from_gemspec
|
|
134
|
-
File.basename(gemspec_path, ".gemspec")
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
# The path to the app's gemspec (raises when not found).
|
|
138
|
-
def gemspec_path
|
|
139
|
-
@gemspec_path ||= Dir.glob(File.join(destination, "*.gemspec")).first || raise(Generators::Error, "Run this command from a Charming app root")
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
# The path to the app's root loader file (`lib/<app_name>.rb`).
|
|
143
|
-
def root_file_path
|
|
144
|
-
File.join(destination, "lib", "#{app_name.snake_name}.rb")
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
# Strips the app destination prefix from *path* for human-friendly status output.
|
|
148
|
-
def relative_path(path)
|
|
149
|
-
path.delete_prefix("#{destination}/")
|
|
150
|
-
end
|
|
151
|
-
end
|
|
152
|
-
end
|
data/lib/charming/focus.rb
DELETED
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Charming
|
|
4
|
-
# Focus manages a stack of focus scopes (rings) for a single controller class. Each scope has
|
|
5
|
-
# a slot ring (a fixed list of named slots) and a current slot within that ring. Multiple
|
|
6
|
-
# scopes can be stacked so the command palette, modals, and layouts can each have their own
|
|
7
|
-
# focus contexts without interfering with one another.
|
|
8
|
-
#
|
|
9
|
-
# State lives under `session[:focus_state][controller_class_name]` so focus persists across
|
|
10
|
-
# controller dispatches within the same session.
|
|
11
|
-
class Focus
|
|
12
|
-
# Returns the Focus object for *controller_class* under the given *session*, creating the
|
|
13
|
-
# underlying session hash if absent.
|
|
14
|
-
def self.for(session, controller_class)
|
|
15
|
-
session[:focus_state] ||= {}
|
|
16
|
-
key = controller_class.name
|
|
17
|
-
session[:focus_state][key] ||= {scopes: []}
|
|
18
|
-
new(session[:focus_state][key])
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def initialize(state)
|
|
22
|
-
@state = state
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
# Defines the primary focus ring for the controller with the given *slots*. Only effective
|
|
26
|
-
# the first time it is called; subsequent calls are no-ops.
|
|
27
|
-
def define(slots)
|
|
28
|
-
return if @state[:scopes].any? { |scope| scope[:origin] == :ring }
|
|
29
|
-
|
|
30
|
-
@state[:scopes] << build_scope(slots, :ring)
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
# Defines a layout scope (inserted after the primary ring and before modal scopes). *slots*
|
|
34
|
-
# is the list of pane names; the previously-focused layout slot is preserved when it is still
|
|
35
|
-
# part of the new ring.
|
|
36
|
-
def define_layout(slots)
|
|
37
|
-
current = current_layout_slot(slots)
|
|
38
|
-
remove_scope(:layout)
|
|
39
|
-
return if slots.empty?
|
|
40
|
-
|
|
41
|
-
@state[:scopes].insert(layout_scope_index, build_scope(slots, :layout, current))
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
# Pushes a new focus scope with the given *slots* onto the stack. Used by modals, palettes,
|
|
45
|
-
# and other overlays. *origin* is a label for the scope kind.
|
|
46
|
-
def push_scope(slots, origin: :modal)
|
|
47
|
-
@state[:scopes] << build_scope(slots, origin)
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
# Pops the topmost focus scope from the stack.
|
|
51
|
-
def pop_scope
|
|
52
|
-
@state[:scopes].pop
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
# Returns the currently focused slot, or nil when no scope is active.
|
|
56
|
-
def current
|
|
57
|
-
top && top[:current]
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
# Returns the slot ring of the topmost scope (an array of slot names). Empty when no scope.
|
|
61
|
-
def ring
|
|
62
|
-
top ? top[:ring] : []
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
# Sets the current slot within the topmost scope to *slot*. No-op when *slot* is not in the ring.
|
|
66
|
-
def focus(slot)
|
|
67
|
-
return unless ring.include?(slot)
|
|
68
|
-
|
|
69
|
-
top[:current] = slot
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
# Cycles focus by *direction* (default +1 forward) within the topmost ring. No-op on an empty ring.
|
|
73
|
-
def cycle(direction = +1)
|
|
74
|
-
return if ring.empty?
|
|
75
|
-
|
|
76
|
-
index = ring.index(current) || 0
|
|
77
|
-
top[:current] = ring[(index + direction) % ring.length]
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
# True when *slot* is the current focus slot.
|
|
81
|
-
def focused?(slot)
|
|
82
|
-
current == slot
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
private
|
|
86
|
-
|
|
87
|
-
# Returns the topmost scope hash (the last entry pushed onto `@state[:scopes]`).
|
|
88
|
-
def top
|
|
89
|
-
@state[:scopes].last
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
# Removes every scope whose origin equals *origin* (in place).
|
|
93
|
-
def remove_scope(origin)
|
|
94
|
-
@state[:scopes].reject! { |scope| scope[:origin] == origin }
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
# Returns the index in the scope stack where a layout scope belongs: just before the first
|
|
98
|
-
# non-ring, non-layout scope (i.e., at the end of the "structural" stack).
|
|
99
|
-
def layout_scope_index
|
|
100
|
-
index = @state[:scopes].index { |scope| !%i[ring layout].include?(scope[:origin]) }
|
|
101
|
-
index || @state[:scopes].length
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
# Returns the current layout scope's current slot, but only when it is still part of *slots*.
|
|
105
|
-
# Otherwise returns the first slot in *slots* (so a new layout reverts to its first pane).
|
|
106
|
-
def current_layout_slot(slots)
|
|
107
|
-
current_slot = current_layout_scope&.fetch(:current)
|
|
108
|
-
slots.include?(current_slot) ? current_slot : slots.first
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
# Returns the layout scope, or nil when no layout scope is present.
|
|
112
|
-
def current_layout_scope
|
|
113
|
-
@state[:scopes].find { |scope| scope[:origin] == :layout }
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
# Builds an immutable scope hash with the given *slots*, *origin*, and starting *current* slot.
|
|
117
|
-
def build_scope(slots, origin, current = slots.first)
|
|
118
|
-
{ring: slots.dup.freeze, current: current, origin: origin}
|
|
119
|
-
end
|
|
120
|
-
end
|
|
121
|
-
end
|
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Charming
|
|
4
|
-
module Markdown
|
|
5
|
-
# BlockRenderer dispatches Kramdown block-level elements (paragraph, header, list,
|
|
6
|
-
# code block, etc.) to their individual rendering handlers. Handlers are built once
|
|
7
|
-
# at construction time as a frozen hash of element-type symbols to callables.
|
|
8
|
-
class BlockRenderer
|
|
9
|
-
# *renderer* is the parent Renderer (used to wrap text, render inlines, and look up styles).
|
|
10
|
-
def initialize(renderer:)
|
|
11
|
-
@renderer = renderer
|
|
12
|
-
build_handlers
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
# Renders *element* using the handler registered for `element.type`. Unknown types
|
|
16
|
-
# fall through to `render_unknown`.
|
|
17
|
-
def render(element, context:)
|
|
18
|
-
handler = @handlers[element.type] || method(:render_unknown)
|
|
19
|
-
handler.call(element, context)
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
private
|
|
23
|
-
|
|
24
|
-
# The frozen hash of element-type → handler mapping.
|
|
25
|
-
attr_reader :handlers
|
|
26
|
-
|
|
27
|
-
# Builds the handler hash. Each handler is a small lambda that calls back into the
|
|
28
|
-
# parent renderer (or one of the private render_* methods below).
|
|
29
|
-
def build_handlers
|
|
30
|
-
r = @renderer
|
|
31
|
-
@handlers = {
|
|
32
|
-
p: ->(element, context) { r.wrap(r.render_inlines(element.children), width: context.width) },
|
|
33
|
-
header: ->(element, context) { send(:render_header, element, context) },
|
|
34
|
-
blockquote: ->(element, context) { send(:render_blockquote, element, context) },
|
|
35
|
-
ul: ->(element, context) { send(:render_list, element, ordered: false, context: context) },
|
|
36
|
-
ol: ->(element, context) { send(:render_list, element, ordered: true, context: context) },
|
|
37
|
-
li: ->(element, context) { r.render_blocks(element.children, list_depth: context.list_depth, width: context.width) },
|
|
38
|
-
codeblock: ->(element, _context) { send(:render_codeblock, element) },
|
|
39
|
-
hr: ->(element, context) { send(:render_rule, width: context.width) },
|
|
40
|
-
blank: ->(_element, _context) {}
|
|
41
|
-
}.freeze
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
# Fallback for unknown block types: wraps the raw value when there are no children,
|
|
45
|
-
# otherwise recurses into the children.
|
|
46
|
-
def render_unknown(element, context)
|
|
47
|
-
return @renderer.wrap(element.value.to_s, width: context.width) if element.children.empty?
|
|
48
|
-
|
|
49
|
-
@renderer.render_blocks(element.children, list_depth: context.list_depth, width: context.width)
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
# Renders a header element, using the `markdown_heading` style for h1 and the
|
|
53
|
-
# `markdown_subheading` style for h2+.
|
|
54
|
-
def render_header(element, context)
|
|
55
|
-
rendered = @renderer.wrap(@renderer.render_inlines(element.children), width: context.width)
|
|
56
|
-
style = if element.options[:level].to_i == 1
|
|
57
|
-
@renderer.style_for(:markdown_heading, fallback: @renderer.theme_style(:title))
|
|
58
|
-
else
|
|
59
|
-
@renderer.style_for(:markdown_subheading, fallback: @renderer.theme_style(:title))
|
|
60
|
-
end
|
|
61
|
-
style.render(rendered)
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def render_blockquote(element, context)
|
|
65
|
-
quote_width = context.width ? [context.width - 2, 1].max : nil
|
|
66
|
-
rendered = @renderer.render_blocks(element.children, list_depth: context.list_depth, width: quote_width)
|
|
67
|
-
border = @renderer.style_for(:markdown_quote_border, fallback: @renderer.theme_style(:border)).render("|")
|
|
68
|
-
quote_style = @renderer.style_for(:markdown_quote, fallback: @renderer.theme_style(:muted))
|
|
69
|
-
|
|
70
|
-
rendered.lines(chomp: true).map { |line| "#{border} #{quote_style.render(line)}" }.join("\n")
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def render_list(element, ordered:, context:)
|
|
74
|
-
element.children.each_with_index.map do |item, index|
|
|
75
|
-
marker = ordered ? "#{ordered_start(element) + index}." : "-"
|
|
76
|
-
render_list_item(item, marker: marker, context: context)
|
|
77
|
-
end.join("\n")
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def render_list_item(element, marker:, context:)
|
|
81
|
-
indent = " " * context.list_depth
|
|
82
|
-
first_prefix = "#{indent}#{marker} "
|
|
83
|
-
rest_prefix = "#{indent}#{" " * (marker.length + 1)}"
|
|
84
|
-
item_width = context.width ? [context.width - UI::Width.measure(first_prefix), 1].max : nil
|
|
85
|
-
body = @renderer.render_blocks(element.children, list_depth: context.list_depth + 1, width: item_width)
|
|
86
|
-
|
|
87
|
-
body.lines(chomp: true).each_with_index.map do |line, index|
|
|
88
|
-
"#{index.zero? ? first_prefix : rest_prefix}#{line}"
|
|
89
|
-
end.join("\n")
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def ordered_start(element)
|
|
93
|
-
element.options.fetch(:start, 1).to_i
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def render_codeblock(element)
|
|
97
|
-
code = element.value.to_s
|
|
98
|
-
rendered = if @renderer.syntax_highlighting
|
|
99
|
-
SyntaxHighlighter.new(theme: @renderer.theme).render(code, language: code_language(element))
|
|
100
|
-
else
|
|
101
|
-
@renderer.style_for(:markdown_code, fallback: @renderer.theme_style(:warn)).render(code)
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
rendered.lines(chomp: true).map { |line| " #{line}" }.join("\n")
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def render_rule(width:)
|
|
108
|
-
@renderer.style_for(:markdown_rule, fallback: @renderer.theme_style(:border)).render("-" * (width || Renderer::DEFAULT_RULE_WIDTH))
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
def code_language(element)
|
|
112
|
-
return element.options[:lang] if element.options[:lang]
|
|
113
|
-
|
|
114
|
-
element.attr["class"].to_s[/language-([^\s]+)/, 1]
|
|
115
|
-
end
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
end
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Charming
|
|
4
|
-
module Markdown
|
|
5
|
-
# InlineRenderer dispatches Kramdown inline-level elements (text, strong, em,
|
|
6
|
-
# codespan, link, line break, HTML entity) to their individual rendering handlers.
|
|
7
|
-
# Handlers are built once at construction as a frozen hash of element-type symbols
|
|
8
|
-
# to callables.
|
|
9
|
-
class InlineRenderer
|
|
10
|
-
# *renderer* is the parent Renderer (used to render nested inlines and look up styles).
|
|
11
|
-
def initialize(renderer:)
|
|
12
|
-
@renderer = renderer
|
|
13
|
-
build_handlers
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
# Renders *element* using the handler registered for `element.type`. Unknown types
|
|
17
|
-
# fall through to `render_unknown`.
|
|
18
|
-
def render(element, context:)
|
|
19
|
-
handler = @handlers[element.type] || method(:render_unknown)
|
|
20
|
-
handler.call(element, context)
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
private
|
|
24
|
-
|
|
25
|
-
# The frozen hash of element-type → handler mapping.
|
|
26
|
-
attr_reader :handlers
|
|
27
|
-
|
|
28
|
-
# Builds the handler hash for text, strong, em, codespan, link, br, and entity.
|
|
29
|
-
def build_handlers
|
|
30
|
-
r = @renderer
|
|
31
|
-
@handlers = {
|
|
32
|
-
text: ->(element, _context) { element.value.to_s },
|
|
33
|
-
strong: ->(element, context) { render_styled(element, context, :markdown_strong) { |s| s.bold } },
|
|
34
|
-
em: ->(element, context) { render_styled(element, context, :markdown_emphasis) { |s| s.italic } },
|
|
35
|
-
codespan: ->(element, _context) { r.style_for(:markdown_inline_code, fallback: r.theme_style(:warn)).render(element.value.to_s) },
|
|
36
|
-
a: ->(element, context) { send(:render_link, element, context) },
|
|
37
|
-
br: ->(_element, _context) { "\n" },
|
|
38
|
-
entity: ->(element, _context) { element.value.respond_to?(:char) ? element.value.char : element.value.to_s }
|
|
39
|
-
}.freeze
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
# Renders a styled inline (strong/em) by first rendering children, then applying
|
|
43
|
-
# the theme style and the block-form (e.g., `bold`/`italic`) decoration.
|
|
44
|
-
def render_styled(element, context, style_name)
|
|
45
|
-
rendered = @renderer.render_inlines(element.children, width: context.width)
|
|
46
|
-
style = @renderer.style_for(style_name, fallback: yield(@renderer.theme_style(:text)))
|
|
47
|
-
style.render(rendered)
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
# Renders a Markdown link as "label <href>" (URL omitted when empty), styled with
|
|
51
|
-
# the markdown_link theme token or the info+underline fallback.
|
|
52
|
-
def render_link(element, context)
|
|
53
|
-
label = @renderer.render_inlines(element.children, width: context.width)
|
|
54
|
-
href = element.attr["href"].to_s
|
|
55
|
-
rendered = href.empty? ? label : "#{label} <#{href}>"
|
|
56
|
-
@renderer.style_for(:markdown_link, fallback: @renderer.theme_style(:info).underline).render(rendered)
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# Fallback for unknown inline types: returns the value when there are no children,
|
|
60
|
-
# otherwise recurses into the children.
|
|
61
|
-
def render_unknown(element, context)
|
|
62
|
-
element.children.empty? ? element.value.to_s : @renderer.render_inlines(element.children, width: context.width)
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
end
|