docyard 1.2.0 → 1.4.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: fe9582d35d963462ff818674735f3b131607368f6adc6fdb70eac6152365efe8
4
- data.tar.gz: a8e8b424702b4ca3ff2726a938ea0696f0b15bed3d82f449f803f64923bbbafc
3
+ metadata.gz: c329f7664eff5bca0fded133cdc29fffa5eea23f81a0183605ea1f038adfa819
4
+ data.tar.gz: 5f4c135cd6a7dc25140d8111940d6a2fda14c4c9b142adb3d0c8b8a2c91a8f30
5
5
  SHA512:
6
- metadata.gz: 4c6ae9f9e88f186d43aa13ef62c2c96d3c4ed65e0320558413df73915bd33478b673dae25dc4f3e3fdf6b0fe627ca595a8fc53cded6186ed3afb03ae08ef7f5d
7
- data.tar.gz: 17ad431713b55f78b87a67b5de17db1e58c949895a08ff9d31b6f658aa277a8df8cab84f3d3ee178f9c70a9eb30557089fc0b732e2793b6c9b3c8054bf513b1c
6
+ metadata.gz: bb2ccd83fef6197381df0dee879b71005fa6745842c242bd2fe4e2622d172356f0919b165c63791d6999f04f1cf8715f4dc5338d6050203f5133345ad52fcd9a
7
+ data.tar.gz: 7a6ea46ba7dd06050e4157c018d158c904a9fbb86dcce352446a4b7d8c99199b641cd8094d5a5eb4074e59716df2abd3e30925fc19c8d4232f365a11d20524a5
data/CHANGELOG.md CHANGED
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.4.0] - 2026-02-22
11
+
12
+ ### Added
13
+ - **Code Annotations** - Clickable inline markers in code blocks with popover explanations using `// (1)` syntax and a matching ordered list (#156)
14
+ - **Variables** - Template variables with `{{ variable }}` syntax, defined in `docyard.yml` under `variables:` (#155)
15
+
16
+ ### Documentation
17
+ - Added Code Annotations section to Code Blocks page
18
+ - Added Variables page with usage examples and configuration reference
19
+
20
+ ## [1.3.0] - 2026-02-17
21
+
22
+ ### Added
23
+ - **Deploy Command** - One-command deployment with `docyard deploy` supporting Vercel, Netlify, Cloudflare Pages, and GitHub Pages (#153)
24
+ - **Platform Auto-Detection** - Automatically detects deployment platform from project config files (e.g. `vercel.json`, `netlify.toml`)
25
+
26
+ ### Documentation
27
+ - Added Deploy Command page with per-platform setup instructions
28
+ - Updated CLI reference with `docyard deploy` options
29
+ - Cross-linked existing GitHub Pages, Vercel, and Netlify docs to deploy command
30
+
10
31
  ## [1.2.0] - 2026-02-03
11
32
 
12
33
  ### Added
@@ -264,7 +285,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
264
285
  - Initial gem structure
265
286
  - Project scaffolding
266
287
 
267
- [Unreleased]: https://github.com/sanifhimani/docyard/compare/v1.2.0...HEAD
288
+ [Unreleased]: https://github.com/sanifhimani/docyard/compare/v1.4.0...HEAD
289
+ [1.4.0]: https://github.com/sanifhimani/docyard/compare/v1.3.0...v1.4.0
290
+ [1.3.0]: https://github.com/sanifhimani/docyard/compare/v1.2.0...v1.3.0
268
291
  [1.2.0]: https://github.com/sanifhimani/docyard/compare/v1.1.0...v1.2.0
269
292
  [1.1.0]: https://github.com/sanifhimani/docyard/compare/v1.0.2...v1.1.0
270
293
  [1.0.2]: https://github.com/sanifhimani/docyard/compare/v1.0.1...v1.0.2
data/lib/docyard/cli.rb CHANGED
@@ -80,6 +80,23 @@ module Docyard
80
80
  exit(doctor.run)
81
81
  end
82
82
 
83
+ desc "deploy", "Deploy the built site to a hosting platform"
84
+ method_option :to, type: :string, desc: "Target platform (vercel, netlify, cloudflare, github-pages)"
85
+ method_option :prod, type: :boolean, default: true, desc: "Deploy to production"
86
+ method_option :skip_build, type: :boolean, default: false, desc: "Skip building before deploy"
87
+ def deploy
88
+ apply_global_options
89
+ require_relative "deploy/deployer"
90
+ deployer = Deploy::Deployer.new(
91
+ to: options[:to],
92
+ production: options[:prod],
93
+ skip_build: options[:skip_build]
94
+ )
95
+ exit(1) unless deployer.deploy
96
+ rescue ConfigError => e
97
+ print_config_error(e)
98
+ end
99
+
83
100
  desc "customize", "Generate theme customization files"
84
101
  method_option :minimal, type: :boolean, default: false, aliases: "-m",
85
102
  desc: "Generate minimal files without comments"
@@ -10,6 +10,7 @@ module Docyard
10
10
  CalloutProcessor = Processors::CalloutProcessor
11
11
  CodeBlockProcessor = Processors::CodeBlockProcessor
12
12
  CodeGroupProcessor = Processors::CodeGroupProcessor
13
+ CodeBlockAnnotationPreprocessor = Processors::CodeBlockAnnotationPreprocessor
13
14
  CodeBlockDiffPreprocessor = Processors::CodeBlockDiffPreprocessor
14
15
  CodeBlockFocusPreprocessor = Processors::CodeBlockFocusPreprocessor
15
16
  CodeBlockOptionsPreprocessor = Processors::CodeBlockOptionsPreprocessor
@@ -25,6 +26,7 @@ module Docyard
25
26
  TableWrapperProcessor = Processors::TableWrapperProcessor
26
27
  TabsProcessor = Processors::TabsProcessor
27
28
  TooltipProcessor = Processors::TooltipProcessor
29
+ VariablesProcessor = Processors::VariablesProcessor
28
30
 
29
31
  CodeBlockFeatureExtractor = Support::CodeBlock::FeatureExtractor
30
32
  CodeBlockLineWrapper = Support::CodeBlock::LineWrapper
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kramdown"
4
+ require "kramdown-parser-gfm"
5
+ require_relative "../base_processor"
6
+ require_relative "../support/code_block/patterns"
7
+ require_relative "../support/code_block/annotation_list_parser"
8
+
9
+ module Docyard
10
+ module Components
11
+ module Processors
12
+ class CodeBlockAnnotationPreprocessor < BaseProcessor
13
+ include Support::CodeBlock::Patterns
14
+
15
+ self.priority = 8
16
+
17
+ CODE_BLOCK_REGEX = /^```(\w*).*?\n(.*?)^```/m
18
+ TABS_BLOCK_REGEX = /^:::tabs[ \t]*\n.*?^:::[ \t]*$/m
19
+ CODE_GROUP_REGEX = /^:::code-group[ \t]*\n.*?^:::[ \t]*$/m
20
+
21
+ ListParser = Support::CodeBlock::AnnotationListParser
22
+
23
+ def preprocess(content)
24
+ initialize_state
25
+ @skip_ranges = find_skip_ranges(content)
26
+ process_content(content)
27
+ end
28
+
29
+ private
30
+
31
+ def initialize_state
32
+ context[:code_block_annotation_markers] ||= []
33
+ context[:code_block_annotation_content] ||= []
34
+ @block_index = 0
35
+ end
36
+
37
+ def process_content(content)
38
+ result = +""
39
+ last_end = 0
40
+
41
+ content.scan(CODE_BLOCK_REGEX) do
42
+ match = Regexp.last_match
43
+ next if match.begin(0) < last_end
44
+
45
+ result << content[last_end...match.begin(0)]
46
+ processed, new_end = process_match(match, content)
47
+ result << processed
48
+ last_end = new_end
49
+ end
50
+
51
+ result << content[last_end..]
52
+ end
53
+
54
+ def process_match(match, content)
55
+ if inside_skip_range?(match.begin(0))
56
+ [match[0], match.end(0)]
57
+ else
58
+ process_annotated_block(match, content)
59
+ end
60
+ end
61
+
62
+ def process_annotated_block(match, content)
63
+ markers = extract_annotation_markers(match[2])
64
+ code_end = match.end(0)
65
+
66
+ if markers.any?
67
+ process_with_list(match, content, markers, code_end)
68
+ else
69
+ store_empty_markers
70
+ @block_index += 1
71
+ [match[0], code_end]
72
+ end
73
+ end
74
+
75
+ def process_with_list(match, content, markers, code_end)
76
+ list_result = ListParser.find_after_code_block(content, code_end)
77
+
78
+ if list_result
79
+ store_markers_and_content(markers, list_result[:items])
80
+ cleaned_code = strip_annotation_markers(match[2])
81
+ @block_index += 1
82
+ ["#{match[0].sub(match[2], cleaned_code)}\n", list_result[:end_position]]
83
+ else
84
+ store_empty_markers
85
+ @block_index += 1
86
+ [match[0], code_end]
87
+ end
88
+ end
89
+
90
+ def store_markers_and_content(markers, list_items)
91
+ context[:code_block_annotation_markers][@block_index] = markers
92
+ context[:code_block_annotation_content][@block_index] = render_annotation_content(list_items)
93
+ end
94
+
95
+ def store_empty_markers
96
+ context[:code_block_annotation_markers][@block_index] = {}
97
+ context[:code_block_annotation_content][@block_index] = {}
98
+ end
99
+
100
+ def extract_annotation_markers(code_content)
101
+ markers = {}
102
+ code_content.lines.each_with_index do |line, index|
103
+ next unless (match = line.match(ANNOTATION_MARKER_PATTERN))
104
+
105
+ num = match.captures.compact.first.to_i
106
+ markers[index + 1] = num
107
+ end
108
+ markers
109
+ end
110
+
111
+ def strip_annotation_markers(code_content)
112
+ code_content.lines.map { |line| strip_single_marker(line) }.join
113
+ end
114
+
115
+ def strip_single_marker(line)
116
+ return line unless line.match?(ANNOTATION_MARKER_PATTERN)
117
+
118
+ stripped = line.sub(ANNOTATION_MARKER_PATTERN, "")
119
+ stripped.end_with?("\n") ? stripped : "#{stripped}\n"
120
+ end
121
+
122
+ def render_annotation_content(list_items)
123
+ list_items.transform_values { |markdown_text| render_markdown(markdown_text) }
124
+ end
125
+
126
+ def render_markdown(text)
127
+ Kramdown::Document.new(text, input: "GFM", hard_wrap: false).to_html.strip
128
+ end
129
+
130
+ def inside_skip_range?(position)
131
+ @skip_ranges.any? { |range| range.cover?(position) }
132
+ end
133
+
134
+ def find_skip_ranges(content)
135
+ find_ranges(content, TABS_BLOCK_REGEX) + find_ranges(content, CODE_GROUP_REGEX)
136
+ end
137
+
138
+ def find_ranges(content, pattern)
139
+ ranges = []
140
+ content.scan(pattern) do
141
+ match = Regexp.last_match
142
+ ranges << (match.begin(0)...match.end(0))
143
+ end
144
+ ranges
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -33,11 +33,17 @@ module Docyard
33
33
  def initialize_postprocess_state(html)
34
34
  @block_index = 0
35
35
  @options = context[:code_block_options] || []
36
+ initialize_line_feature_state
37
+ @tabs_ranges = TabsRangeFinder.find_ranges(html)
38
+ end
39
+
40
+ def initialize_line_feature_state
36
41
  @diff_lines = context[:code_block_diff_lines] || []
37
42
  @focus_lines = context[:code_block_focus_lines] || []
38
43
  @error_lines = context[:code_block_error_lines] || []
39
44
  @warning_lines = context[:code_block_warning_lines] || []
40
- @tabs_ranges = TabsRangeFinder.find_ranges(html)
45
+ @annotation_markers = context[:code_block_annotation_markers] || []
46
+ @annotation_content = context[:code_block_annotation_content] || []
41
47
  end
42
48
 
43
49
  def process_all_highlight_blocks(html)
@@ -99,25 +105,28 @@ module Docyard
99
105
 
100
106
  def build_block_data(code_text, opts, show_line_numbers, start_line, title_data)
101
107
  {
102
- text: code_text,
103
- highlights: opts[:highlights],
108
+ text: code_text, highlights: opts[:highlights],
109
+ show_line_numbers: show_line_numbers, start_line: start_line,
110
+ line_numbers: show_line_numbers ? LineNumbers.generate_numbers(code_text, start_line) : [],
111
+ title: title_data[:title], icon: title_data[:icon], icon_source: title_data[:icon_source]
112
+ }.merge(current_line_features)
113
+ end
114
+
115
+ def current_line_features
116
+ {
104
117
  diff_lines: @diff_lines[@block_index] || {},
105
118
  focus_lines: @focus_lines[@block_index] || {},
106
119
  error_lines: @error_lines[@block_index] || {},
107
120
  warning_lines: @warning_lines[@block_index] || {},
108
- show_line_numbers: show_line_numbers,
109
- line_numbers: show_line_numbers ? LineNumbers.generate_numbers(code_text, start_line) : [],
110
- start_line: start_line,
111
- title: title_data[:title],
112
- icon: title_data[:icon],
113
- icon_source: title_data[:icon_source]
121
+ annotation_markers: @annotation_markers[@block_index] || {},
122
+ annotation_content: @annotation_content[@block_index] || {}
114
123
  }
115
124
  end
116
125
 
117
126
  def process_html_for_highlighting(original_html, block_data)
118
127
  needs_wrapping = block_data[:highlights].any? || block_data[:diff_lines].any? ||
119
128
  block_data[:focus_lines].any? || block_data[:error_lines].any? ||
120
- block_data[:warning_lines].any?
129
+ block_data[:warning_lines].any? || block_data[:annotation_markers].any?
121
130
  return original_html unless needs_wrapping
122
131
 
123
132
  wrap_code_block(original_html, block_data)
@@ -150,7 +159,8 @@ module Docyard
150
159
  def line_feature_locals(block_data)
151
160
  { highlights: block_data[:highlights], diff_lines: block_data[:diff_lines],
152
161
  focus_lines: block_data[:focus_lines], error_lines: block_data[:error_lines],
153
- warning_lines: block_data[:warning_lines] }
162
+ warning_lines: block_data[:warning_lines], annotation_markers: block_data[:annotation_markers],
163
+ annotation_content: block_data[:annotation_content] }
154
164
  end
155
165
 
156
166
  def title_locals(block_data)
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../base_processor"
4
+ require_relative "../support/markdown_code_block_helper"
5
+
6
+ module Docyard
7
+ module Components
8
+ module Processors
9
+ class VariablesProcessor < BaseProcessor
10
+ include Support::MarkdownCodeBlockHelper
11
+
12
+ VARIABLE_PATTERN = /\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/
13
+ VARS_SUFFIX_PATTERN = /^(`{3,}|~{3,})(\S+)-vars(.*)/
14
+
15
+ self.priority = 1
16
+
17
+ def preprocess(content)
18
+ variables = context.dig(:config, "variables") || {}
19
+ return content if variables.empty?
20
+
21
+ segments = split_by_code_blocks(content)
22
+ segments.map { |segment| process_segment(segment, variables) }.join
23
+ end
24
+
25
+ private
26
+
27
+ def process_segment(segment, variables)
28
+ return substitute_variables(segment[:content], variables) if segment[:type] == :text
29
+
30
+ match = segment[:content].match(VARS_SUFFIX_PATTERN)
31
+ return segment[:content] unless match
32
+
33
+ stripped = segment[:content].sub("#{match[2]}-vars", match[2])
34
+ substitute_variables(stripped, variables)
35
+ end
36
+
37
+ def substitute_variables(content, variables)
38
+ content.gsub(VARIABLE_PATTERN) do |original|
39
+ key = Regexp.last_match(1)
40
+ value = resolve_variable(key, variables)
41
+ value.nil? ? original : value.to_s
42
+ end
43
+ end
44
+
45
+ def resolve_variable(key, variables)
46
+ keys = key.split(".")
47
+ keys.reduce(variables) do |current, k|
48
+ return nil unless current.is_a?(Hash)
49
+
50
+ current[k]
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module Components
5
+ module Support
6
+ module CodeBlock
7
+ module AnnotationListParser
8
+ ORDERED_LIST_ITEM = /\A(\d+)\.\s+(.*)/
9
+ CONTINUATION_LINE = /\A\s{2,}(\S.*)/
10
+ BLANK_LINE = /\A\s*\z/
11
+ LIST_START = /\A(\s*\n)*(\d+\.\s+)/m
12
+
13
+ module_function
14
+
15
+ def parse(text)
16
+ state = { items: {}, current_num: nil, current_lines: [] }
17
+ catch(:done) { text.each_line { |line| consume_line(state, line) } }
18
+ finalize(state)
19
+ state[:items]
20
+ end
21
+
22
+ def find_after_code_block(content, position)
23
+ rest = content[position..]
24
+ return nil unless rest
25
+
26
+ preamble = rest.match(LIST_START)
27
+ return nil unless preamble
28
+
29
+ list_start = preamble.begin(2)
30
+ list_text = rest[list_start..]
31
+ parsed = parse_with_extent(list_text)
32
+ return nil if parsed[:items].empty?
33
+
34
+ { text: parsed[:consumed], end_position: position + list_start + parsed[:length], items: parsed[:items] }
35
+ end
36
+
37
+ def parse_with_extent(text)
38
+ state = { items: {}, current_num: nil, current_lines: [] }
39
+ consumed_length = 0
40
+
41
+ catch(:done) do
42
+ text.each_line do |line|
43
+ consume_line(state, line)
44
+ consumed_length += line.length
45
+ end
46
+ end
47
+
48
+ finalize(state)
49
+ { items: state[:items], consumed: text[0...consumed_length], length: consumed_length }
50
+ end
51
+
52
+ def consume_line(state, line)
53
+ if (match = line.match(ORDERED_LIST_ITEM))
54
+ start_new_item(state, match)
55
+ elsif state[:current_num] && (cont = line.match(CONTINUATION_LINE))
56
+ state[:current_lines] << cont[1].rstrip
57
+ elsif state[:current_num] && line.match?(BLANK_LINE)
58
+ state[:current_lines] << ""
59
+ else
60
+ finalize(state)
61
+ throw :done
62
+ end
63
+ end
64
+
65
+ def start_new_item(state, match)
66
+ finalize(state)
67
+ state[:current_num] = match[1].to_i
68
+ state[:current_lines] = [match[2].rstrip]
69
+ end
70
+
71
+ def finalize(state)
72
+ return unless state[:current_num]
73
+
74
+ state[:items][state[:current_num]] = state[:current_lines].join("\n").strip
75
+ state[:current_num] = nil
76
+ state[:current_lines] = []
77
+ end
78
+
79
+ private_class_method :consume_line, :start_new_item, :finalize, :parse_with_extent
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -24,10 +24,18 @@ module Docyard
24
24
  source_line = index + 1
25
25
  display_line = block_data[:start_line] + index
26
26
  classes = build_line_classes(source_line, display_line, block_data)
27
- %(<span class="#{classes}">#{line}</span>)
27
+ annotation = annotation_badge(source_line, block_data)
28
+ line_with_badge = insert_before_newline(line, annotation)
29
+ %(<span class="#{classes}">#{line_with_badge}</span>)
28
30
  end
29
31
  end
30
32
 
33
+ def insert_before_newline(line, annotation)
34
+ return line if annotation.empty?
35
+
36
+ line.end_with?("\n") ? "#{line.chomp}#{annotation}\n" : "#{line}#{annotation}"
37
+ end
38
+
31
39
  DIFF_CLASSES = { addition: "docyard-code-line--diff-add", deletion: "docyard-code-line--diff-remove" }.freeze
32
40
 
33
41
  def build_line_classes(source_line, display_line, block_data)
@@ -43,6 +51,22 @@ module Docyard
43
51
  ("docyard-code-line--warning" if block_data[:warning_lines][source_line])
44
52
  ].compact
45
53
  end
54
+
55
+ ANNOTATION_ICON = "<i class=\"ph ph-plus-circle\" aria-hidden=\"true\"></i>"
56
+
57
+ def annotation_badge(source_line, block_data)
58
+ markers = block_data[:annotation_markers] || {}
59
+ num = markers[source_line]
60
+ return "" unless num
61
+
62
+ content = (block_data[:annotation_content] || {})[num]
63
+ return "" unless content
64
+
65
+ escaped = content.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
66
+ %(<button class="docyard-code-annotation" data-annotation-id="#{num}") +
67
+ %( data-annotation-content="#{escaped}" type="button" aria-label="Annotation #{num}">) +
68
+ %(#{ANNOTATION_ICON}</button>)
69
+ end
46
70
  end
47
71
  end
48
72
  end
@@ -48,6 +48,17 @@ module Docyard
48
48
  ;\s*\[!code\s+warning\]
49
49
  )[^\S\n]*
50
50
  }x
51
+
52
+ ANNOTATION_MARKER_PATTERN = %r{
53
+ (?:
54
+ //\s*\((\d+)\)\s*$ |
55
+ \#\s*\((\d+)\)\s*$ |
56
+ /\*\s*\((\d+)\)\s*\*/\s*$ |
57
+ --\s*\((\d+)\)\s*$ |
58
+ <!--\s*\((\d+)\)\s*-->\s*$ |
59
+ ;\s*\((\d+)\)\s*$
60
+ )
61
+ }x
51
62
  end
52
63
  end
53
64
  end
@@ -23,7 +23,8 @@ module Docyard
23
23
  repo: REPO_SCHEMA,
24
24
  analytics: ANALYTICS_SCHEMA,
25
25
  feedback: FEEDBACK_SCHEMA,
26
- social_cards: SOCIAL_CARDS_SCHEMA
26
+ social_cards: SOCIAL_CARDS_SCHEMA,
27
+ variables: { type: :hash, allow_extra_keys: true, keys: {} }
27
28
  }.freeze
28
29
  end
29
30
  end
@@ -27,7 +27,8 @@ module Docyard
27
27
  "last_updated" => true },
28
28
  "analytics" => { "google" => nil, "plausible" => nil, "fathom" => nil, "script" => nil },
29
29
  "feedback" => { "enabled" => false, "question" => "Was this page helpful?" },
30
- "social_cards" => { "enabled" => false }
30
+ "social_cards" => { "enabled" => false },
31
+ "variables" => {}
31
32
  }.freeze
32
33
 
33
34
  attr_reader :data, :file_path
@@ -65,6 +66,7 @@ module Docyard
65
66
  def analytics = @analytics ||= Section.new(data["analytics"])
66
67
  def feedback = @feedback ||= Section.new(data["feedback"])
67
68
  def social_cards = @social_cards ||= Section.new(data["social_cards"])
69
+ def variables = data["variables"]
68
70
 
69
71
  def announcement
70
72
  @announcement ||= data["announcement"] ? Section.new(data["announcement"]) : nil
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Docyard
6
+ module Deploy
7
+ module Adapters
8
+ class Base
9
+ attr_reader :output_dir, :production, :config
10
+
11
+ def initialize(output_dir:, production:, config:)
12
+ @output_dir = output_dir
13
+ @production = production
14
+ @config = config
15
+ end
16
+
17
+ def deploy
18
+ check_cli_installed!
19
+ run_deploy
20
+ end
21
+
22
+ def platform_name
23
+ raise NotImplementedError
24
+ end
25
+
26
+ private
27
+
28
+ def cli_name
29
+ raise NotImplementedError
30
+ end
31
+
32
+ def cli_install_hint
33
+ raise NotImplementedError
34
+ end
35
+
36
+ def run_deploy
37
+ raise NotImplementedError
38
+ end
39
+
40
+ def check_cli_installed!
41
+ _, _, status = Open3.capture3("which", cli_name)
42
+ return if status.success?
43
+
44
+ raise DeployError, "'#{cli_name}' CLI not found. Install it with: #{cli_install_hint}"
45
+ end
46
+
47
+ def execute_command(*)
48
+ stdout, stderr, status = Open3.capture3(*)
49
+ return stdout if status.success?
50
+
51
+ raise DeployError, "Deploy command failed: #{stderr.strip.empty? ? stdout.strip : stderr.strip}"
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Docyard
6
+ module Deploy
7
+ module Adapters
8
+ class Cloudflare < Base
9
+ def platform_name
10
+ "Cloudflare Pages"
11
+ end
12
+
13
+ private
14
+
15
+ def cli_name
16
+ "wrangler"
17
+ end
18
+
19
+ def cli_install_hint
20
+ "npm i -g wrangler"
21
+ end
22
+
23
+ def run_deploy
24
+ output = execute_command("wrangler", "pages", "deploy", output_dir, "--project-name=#{project_name}")
25
+ extract_url(output)
26
+ end
27
+
28
+ def project_name
29
+ config.title.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")
30
+ end
31
+
32
+ def extract_url(output)
33
+ output.match(%r{https://\S+\.pages\.dev\S*})&.to_s
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end