plan_driven 0.1.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.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +76 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +823 -0
  5. data/exe/plan-driven +7 -0
  6. data/lib/generators/plan_driven/install_generator.rb +37 -0
  7. data/lib/generators/plan_driven/templates/create_plan_driven_tables.rb.tt +73 -0
  8. data/lib/generators/plan_driven/templates/plan_driven.rb +39 -0
  9. data/lib/plan_driven/agent_prompt.rb +86 -0
  10. data/lib/plan_driven/cli/plan_commands.rb +158 -0
  11. data/lib/plan_driven/cli/setup_commands.rb +97 -0
  12. data/lib/plan_driven/cli/ticket_commands.rb +155 -0
  13. data/lib/plan_driven/cli/ui.rb +101 -0
  14. data/lib/plan_driven/cli.rb +150 -0
  15. data/lib/plan_driven/configuration.rb +114 -0
  16. data/lib/plan_driven/credentials.rb +90 -0
  17. data/lib/plan_driven/cursor_agents.rb +82 -0
  18. data/lib/plan_driven/cursor_llm.mjs +70 -0
  19. data/lib/plan_driven/cursor_llm.rb +79 -0
  20. data/lib/plan_driven/delivery.rb +362 -0
  21. data/lib/plan_driven/drafter.rb +122 -0
  22. data/lib/plan_driven/errors.rb +22 -0
  23. data/lib/plan_driven/evidence.rb +108 -0
  24. data/lib/plan_driven/gherkin.rb +42 -0
  25. data/lib/plan_driven/github.rb +118 -0
  26. data/lib/plan_driven/guards/migration_guard.rb +94 -0
  27. data/lib/plan_driven/guards/plan_guard.rb +84 -0
  28. data/lib/plan_driven/guards/pr_guard.rb +140 -0
  29. data/lib/plan_driven/guards/ticket_guard.rb +178 -0
  30. data/lib/plan_driven/guards/ticket_normalizer.rb +90 -0
  31. data/lib/plan_driven/guards.rb +57 -0
  32. data/lib/plan_driven/http.rb +60 -0
  33. data/lib/plan_driven/json_reply.rb +26 -0
  34. data/lib/plan_driven/llm.rb +80 -0
  35. data/lib/plan_driven/models/approval.rb +12 -0
  36. data/lib/plan_driven/models/event.rb +13 -0
  37. data/lib/plan_driven/models/evidence_run.rb +17 -0
  38. data/lib/plan_driven/models/plan.rb +98 -0
  39. data/lib/plan_driven/models/record.rb +8 -0
  40. data/lib/plan_driven/models/ticket.rb +58 -0
  41. data/lib/plan_driven/models.rb +8 -0
  42. data/lib/plan_driven/railtie.rb +9 -0
  43. data/lib/plan_driven/renderer/html.rb +106 -0
  44. data/lib/plan_driven/renderer/markdown.rb +145 -0
  45. data/lib/plan_driven/renderer/pdf.rb +39 -0
  46. data/lib/plan_driven/renderer/style.css +22 -0
  47. data/lib/plan_driven/renderer.rb +41 -0
  48. data/lib/plan_driven/repository.rb +35 -0
  49. data/lib/plan_driven/schema_context.rb +98 -0
  50. data/lib/plan_driven/template.rb +113 -0
  51. data/lib/plan_driven/ticket_generator.rb +98 -0
  52. data/lib/plan_driven/version.rb +5 -0
  53. data/lib/plan_driven/workflow.rb +59 -0
  54. data/lib/plan_driven.rb +72 -0
  55. metadata +146 -0
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanDriven
4
+ module Guards
5
+ # Deterministic fixes applied to drafted tickets before they're checked. Anything that has
6
+ # exactly one right answer is corrected here and reported, instead of being sent back to the
7
+ # model for another attempt.
8
+ class TicketNormalizer
9
+ TITLE_PREFIX = { "migration" => "Migration: ", "backfill" => "Data migration: " }.freeze
10
+ KIND_ALIASES = {
11
+ "schema" => "migration", "db" => "migration", "database" => "migration",
12
+ "data_migration" => "backfill", "data" => "backfill", "feature" => "code", "application" => "code",
13
+ "read_switch" => "switch", "contract" => "cleanup", "removal" => "cleanup", "documentation" => "docs"
14
+ }.freeze
15
+
16
+ def initialize(tickets, config: PlanDriven.configuration)
17
+ @tickets = Array(tickets).map { |ticket| ticket.to_h.transform_keys(&:to_s) }
18
+ @config = config
19
+ end
20
+
21
+ def call(report)
22
+ keys = {}
23
+ tickets = @tickets.each_with_index.map { |ticket, index| normalize(ticket, index, keys, report) }
24
+ remap_dependencies(tickets, keys)
25
+ tickets
26
+ end
27
+
28
+ private
29
+
30
+ def normalize(ticket, index, keys, report)
31
+ key = "T#{index + 1}"
32
+ keys[ticket["key"].to_s] = key unless ticket["key"].to_s.empty?
33
+ ticket.merge!("key" => key, "position" => index)
34
+ ticket["kind"] = kind(ticket["kind"], key, report)
35
+ ticket["type"] = ticket["type"].to_s.upcase.presence || "TASK"
36
+ ticket["title"] = title(ticket["title"].to_s.strip, ticket["kind"], key, report)
37
+ ticket["estimate"] = estimate(ticket["estimate"], key, report)
38
+ normalize_lists(ticket)
39
+ end
40
+
41
+ def normalize_lists(ticket)
42
+ ticket["acceptance_criteria"] = list(ticket["acceptance_criteria"])
43
+ ticket["depends_on"] = list(ticket["depends_on"]).map(&:upcase)
44
+ ticket["touches"] = list(ticket["touches"]).map { |table| table.downcase.delete("`") }
45
+ ticket
46
+ end
47
+
48
+ def kind(value, label, report)
49
+ value = value.to_s.downcase.strip.tr(" -", "__")
50
+ return value if Ticket::KINDS.include?(value)
51
+
52
+ mapped = KIND_ALIASES[value] || "code"
53
+ report.fix("#{label}: kind \"#{value}\" read as #{mapped}") unless value.empty?
54
+ mapped
55
+ end
56
+
57
+ def title(value, kind, label, report)
58
+ prefix = TITLE_PREFIX[kind]
59
+ return value if prefix.nil? || value.start_with?(prefix)
60
+
61
+ stripped = value.sub(/\A(data\s+)?migration:\s*/i, "")
62
+ report.fix("#{label}: title prefixed with \"#{prefix.strip}\"")
63
+ "#{prefix}#{stripped}"
64
+ end
65
+
66
+ def estimate(value, label, report)
67
+ number = value.to_s[/\d+/]&.to_i
68
+ return if number.nil?
69
+
70
+ scale = @config.estimate_scale
71
+ return number if scale.include?(number)
72
+
73
+ rounded = scale.find { |point| point >= number } || scale.last
74
+ report.fix("#{label}: estimate #{number} rounded up to #{rounded}, the next point on the scale")
75
+ rounded
76
+ end
77
+
78
+ def list(value)
79
+ items = value.is_a?(Array) ? value : value.to_s.split(/\n|;/)
80
+ items.map { |item| item.to_s.strip.sub(/\A[-*\u2022]\s*/, "") }.reject(&:empty?)
81
+ end
82
+
83
+ def remap_dependencies(tickets, keys)
84
+ tickets.each do |ticket|
85
+ ticket["depends_on"] = ticket["depends_on"].map { |key| keys.fetch(key, key) }.uniq
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanDriven
4
+ # Rules enforced in Ruby rather than asked for in a prompt. A prompt line costs tokens on every
5
+ # call and a model can ignore it; a guard runs every time and says exactly what's wrong.
6
+ module Guards
7
+ # Errors block the next phase. Warnings are shown and recorded, and a human decides.
8
+ class Report
9
+ attr_reader :errors, :warnings, :fixes, :passes
10
+
11
+ def initialize
12
+ @errors = []
13
+ @warnings = []
14
+ @fixes = []
15
+ @passes = []
16
+ end
17
+
18
+ def error(message) = errors << message
19
+ def warning(message) = warnings << message
20
+ def fix(message) = fixes << message
21
+ # What was checked and found fine, so a reviewer can see what the guard looked at.
22
+ def pass(message) = passes << message
23
+
24
+ def ok?
25
+ errors.empty?
26
+ end
27
+
28
+ def merge!(other)
29
+ errors.concat(other.errors)
30
+ warnings.concat(other.warnings)
31
+ fixes.concat(other.fixes)
32
+ passes.concat(other.passes)
33
+ self
34
+ end
35
+
36
+ def to_h
37
+ { "errors" => errors, "warnings" => warnings, "fixes" => fixes, "passes" => passes }
38
+ end
39
+
40
+ def self.from_h(hash)
41
+ report = new
42
+ hash ||= {}
43
+ report.errors.concat(Array(hash["errors"]))
44
+ report.warnings.concat(Array(hash["warnings"]))
45
+ report.fixes.concat(Array(hash["fixes"]))
46
+ report.passes.concat(Array(hash["passes"]))
47
+ report
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ require_relative "guards/plan_guard"
54
+ require_relative "guards/migration_guard"
55
+ require_relative "guards/ticket_normalizer"
56
+ require_relative "guards/ticket_guard"
57
+ require_relative "guards/pr_guard"
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module PlanDriven
8
+ # A small JSON client over net/http. The transport is swappable so the specs never touch the
9
+ # network: anything responding to `call(method, url, headers, body)` and returning
10
+ # `[status, body_string]` works.
11
+ class HTTP
12
+ Response = Struct.new(:status, :body, keyword_init: true) do
13
+ def success?
14
+ status.between?(200, 299)
15
+ end
16
+
17
+ def json
18
+ body.to_s.strip.empty? ? {} : JSON.parse(body)
19
+ rescue JSON::ParserError
20
+ {}
21
+ end
22
+ end
23
+
24
+ class << self
25
+ attr_writer :transport
26
+
27
+ def transport
28
+ @transport ||= method(:net_http)
29
+ end
30
+
31
+ def reset!
32
+ @transport = nil
33
+ end
34
+
35
+ def request(method, url, headers: {}, body: nil, timeout: 60)
36
+ payload = body.nil? || body.is_a?(String) ? body : JSON.generate(body)
37
+ status, text = transport.call(method.to_s.upcase, url, headers, payload, timeout)
38
+ Response.new(status: status.to_i, body: text.to_s)
39
+ rescue Net::OpenTimeout, Net::ReadTimeout
40
+ raise ProviderError, "#{URI(url).host} did not respond within #{timeout}s"
41
+ rescue SocketError, SystemCallError => e
42
+ raise ProviderError, "Could not reach #{URI(url).host}: #{e.message}"
43
+ end
44
+
45
+ private
46
+
47
+ def net_http(method, url, headers, body, timeout)
48
+ uri = URI(url)
49
+ http = Net::HTTP.new(uri.host, uri.port)
50
+ http.use_ssl = uri.scheme == "https"
51
+ http.open_timeout = 10
52
+ http.read_timeout = timeout
53
+ request = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri, headers)
54
+ request.body = body if body
55
+ response = http.request(request)
56
+ [response.code.to_i, response.body]
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanDriven
4
+ # Models wrap JSON in prose or code fences often enough that parsing has to be forgiving,
5
+ # and exact enough that anything else is reported back to the model as an error.
6
+ module JsonReply
7
+ FENCE = /```(?:json)?\s*(.*?)```/m
8
+
9
+ module_function
10
+
11
+ def parse(text)
12
+ candidates(text.to_s).each do |candidate|
13
+ return JSON.parse(candidate)
14
+ rescue JSON::ParserError
15
+ next
16
+ end
17
+ raise InvalidResponseError, "the reply wasn't valid JSON"
18
+ end
19
+
20
+ def candidates(text)
21
+ fenced = text.scan(FENCE).flatten
22
+ braces = text[text.index("{").to_i..(text.rindex("}") || -1)]
23
+ [text, *fenced, braces].compact.map(&:strip).uniq
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanDriven
4
+ # One call: a system prompt and messages in, text out. Planning is where a stronger model pays
5
+ # for itself, so the default is a GPT-4.1 or Claude Sonnet class model, not a mini one, and
6
+ # :cursor drafts with any model on the user's Cursor account (see CursorLLM).
7
+ class LLM
8
+ OPENAI_BASE = "https://api.openai.com/v1"
9
+ ANTHROPIC_BASE = "https://api.anthropic.com/v1"
10
+
11
+ Reply = Struct.new(:text, :input_tokens, :output_tokens, keyword_init: true)
12
+
13
+ attr_reader :config
14
+
15
+ def initialize(config = PlanDriven.configuration, client: nil)
16
+ @config = config
17
+ @client = client
18
+ end
19
+
20
+ def chat(system:, messages:, max_tokens: 8000)
21
+ return wrap(@client.call(system: system, messages: messages)) if @client
22
+
23
+ case config.llm_provider.to_sym
24
+ when :cursor then CursorLLM.new(config).chat(system: system, messages: messages)
25
+ when :anthropic then anthropic(system, messages, max_tokens)
26
+ else openai(system, messages, max_tokens)
27
+ end
28
+ end
29
+
30
+ def label
31
+ "#{config.llm_provider}/#{config.llm_model}"
32
+ end
33
+
34
+ private
35
+
36
+ def anthropic?
37
+ config.llm_provider.to_sym == :anthropic
38
+ end
39
+
40
+ def api_key!
41
+ config.llm_api_key or
42
+ raise ConfigurationError, "No API key for #{config.llm_provider}. Run `plan-driven configure` or set " \
43
+ "#{anthropic? ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY"}."
44
+ end
45
+
46
+ def openai(system, messages, max_tokens)
47
+ body = { model: config.llm_model, temperature: config.temperature, max_completion_tokens: max_tokens,
48
+ response_format: { type: "json_object" },
49
+ messages: [{ role: "system", content: system }] + messages }
50
+ response = post("#{config.llm_api_base || OPENAI_BASE}/chat/completions", body,
51
+ "Authorization" => "Bearer #{api_key!}")
52
+ usage = response["usage"] || {}
53
+ Reply.new(text: response.dig("choices", 0, "message", "content").to_s,
54
+ input_tokens: usage["prompt_tokens"], output_tokens: usage["completion_tokens"])
55
+ end
56
+
57
+ def anthropic(system, messages, max_tokens)
58
+ body = { model: config.llm_model, system: system, max_tokens: max_tokens, temperature: config.temperature,
59
+ messages: messages }
60
+ response = post("#{config.llm_api_base || ANTHROPIC_BASE}/messages", body,
61
+ "x-api-key" => api_key!, "anthropic-version" => "2023-06-01")
62
+ usage = response["usage"] || {}
63
+ Reply.new(text: Array(response["content"]).filter_map { |part| part["text"] }.join,
64
+ input_tokens: usage["input_tokens"], output_tokens: usage["output_tokens"])
65
+ end
66
+
67
+ def post(url, body, headers)
68
+ response = HTTP.request(:post, url, headers: headers.merge("Content-Type" => "application/json"),
69
+ body: body, timeout: config.request_timeout)
70
+ return response.json if response.success?
71
+
72
+ message = response.json.dig("error", "message") || response.body.to_s[0, 200]
73
+ raise ProviderError, "#{config.llm_provider} returned #{response.status}: #{message}"
74
+ end
75
+
76
+ def wrap(value)
77
+ value.is_a?(Reply) ? value : Reply.new(text: value.to_s)
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanDriven
4
+ class Approval < Record
5
+ DECISIONS = %w[approved rejected].freeze
6
+
7
+ belongs_to :approvable, polymorphic: true
8
+
9
+ validates :role, :actor, presence: true
10
+ validates :decision, inclusion: { in: DECISIONS }
11
+ end
12
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanDriven
4
+ # An append-only audit trail: who did what to which plan or ticket, and when.
5
+ class Event < Record
6
+ belongs_to :plan, class_name: "PlanDriven::Plan", inverse_of: :events
7
+ belongs_to :ticket, class_name: "PlanDriven::Ticket", optional: true
8
+
9
+ validates :name, :actor, presence: true
10
+
11
+ before_update { raise ActiveRecord::ReadOnlyRecord, "events are append-only" }
12
+ end
13
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanDriven
4
+ class EvidenceRun < Record
5
+ belongs_to :plan, class_name: "PlanDriven::Plan", inverse_of: :evidence_runs
6
+
7
+ validates :kind, :status, presence: true
8
+
9
+ def scenarios
10
+ Array((results || {})["scenarios"])
11
+ end
12
+
13
+ def passed?
14
+ status == "passed"
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanDriven
4
+ class Plan < Record
5
+ has_many :tickets, -> { order(:position) }, class_name: "PlanDriven::Ticket", dependent: :destroy,
6
+ inverse_of: :plan
7
+ has_many :approvals, as: :approvable, class_name: "PlanDriven::Approval", dependent: :destroy
8
+ has_many :events, -> { order(:created_at, :id) }, class_name: "PlanDriven::Event", dependent: :destroy,
9
+ inverse_of: :plan
10
+ has_many :evidence_runs, -> { order(:created_at) }, class_name: "PlanDriven::EvidenceRun",
11
+ dependent: :destroy, inverse_of: :plan
12
+
13
+ validates :key, :title, presence: true
14
+ validates :key, uniqueness: true
15
+ validates :status, inclusion: { in: Workflow::PLAN.keys }
16
+
17
+ before_validation :assign_key, on: :create
18
+
19
+ def self.find_by_reference!(reference)
20
+ reference = reference.to_s
21
+ find_by(key: reference.upcase) || find_by(id: reference[/\A\d+\z/]) ||
22
+ raise(ActiveRecord::RecordNotFound, "No plan #{reference}. `plan-driven list` shows them all.")
23
+ end
24
+
25
+ def section(key)
26
+ (sections || {})[key.to_s].to_s
27
+ end
28
+
29
+ EDITABLE = %w[draft in_review approved].freeze
30
+
31
+ def editable?
32
+ EDITABLE.include?(status)
33
+ end
34
+
35
+ # Any edit is a new revision, and approvals belong to a revision, so changing an approved
36
+ # plan sends it back for approval.
37
+ def update_sections!(changes, actor:)
38
+ unless editable?
39
+ raise TransitionError, "plan #{key} is #{status}; its tickets are drafted, so the plan can't change. " \
40
+ "Start a follow-up plan instead."
41
+ end
42
+
43
+ merged = (sections || {}).merge(changes.transform_keys(&:to_s))
44
+ transaction do
45
+ update!(sections: merged, revision: revision + 1)
46
+ log!("plan.revised", actor: actor, sections: changes.keys.map(&:to_s))
47
+ Workflow.plan_transition!(self, "draft") unless status == "draft"
48
+ end
49
+ end
50
+
51
+ def slug
52
+ "#{key.downcase}-#{title.parameterize[0, 60]}".delete_suffix("-")
53
+ end
54
+
55
+ def approvals_for_revision
56
+ approvals.where(revision: revision)
57
+ end
58
+
59
+ def approved_roles
60
+ approvals_for_revision.where(decision: "approved").pluck(:role).uniq
61
+ end
62
+
63
+ def missing_approvals(required = PlanDriven.configuration.plan_approvals)
64
+ required.map(&:to_s) - approved_roles
65
+ end
66
+
67
+ def ticket_approved_roles
68
+ approvals.where(role: ticket_roles.map { |role| "tickets:#{role}" }, decision: "approved",
69
+ revision: revision).pluck(:role).map { |role| role.delete_prefix("tickets:") }.uniq
70
+ end
71
+
72
+ def missing_ticket_approvals
73
+ ticket_roles - ticket_approved_roles
74
+ end
75
+
76
+ def log!(name, actor:, ticket: nil, **payload)
77
+ events.create!(name: name, actor: actor, ticket: ticket, payload: payload.presence)
78
+ end
79
+
80
+ def ticket!(reference)
81
+ tickets.find_by(key: reference.to_s.upcase) ||
82
+ raise(ActiveRecord::RecordNotFound, "Plan #{key} has no ticket #{reference}.")
83
+ end
84
+
85
+ private
86
+
87
+ def ticket_roles
88
+ PlanDriven.configuration.ticket_approvals.map(&:to_s)
89
+ end
90
+
91
+ def assign_key
92
+ return if key.present?
93
+
94
+ last = self.class.where("key LIKE 'PD-%'").pluck(:key).map { |value| value.delete_prefix("PD-").to_i }.max
95
+ self.key = "PD-#{last.to_i + 1}"
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanDriven
4
+ class Record < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ self.table_name_prefix = "plan_driven_"
7
+ end
8
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanDriven
4
+ class Ticket < Record
5
+ KINDS = %w[migration dual_write backfill code switch cleanup docs].freeze
6
+ TYPES = %w[TASK STORY BUG].freeze
7
+
8
+ belongs_to :plan, class_name: "PlanDriven::Plan", inverse_of: :tickets
9
+ has_many :approvals, as: :approvable, class_name: "PlanDriven::Approval", dependent: :destroy
10
+
11
+ validates :key, :title, presence: true
12
+ validates :kind, inclusion: { in: KINDS }
13
+ validates :ticket_type, inclusion: { in: TYPES }
14
+ validates :status, inclusion: { in: Workflow::TICKET.keys }
15
+
16
+ def reference
17
+ "#{plan.key}/#{key}"
18
+ end
19
+
20
+ def criteria
21
+ Array(acceptance_criteria)
22
+ end
23
+
24
+ def dependencies
25
+ Array(depends_on)
26
+ end
27
+
28
+ def tables
29
+ Array(touches)
30
+ end
31
+
32
+ def dependency_tickets
33
+ plan.tickets.select { |ticket| dependencies.include?(ticket.key) }
34
+ end
35
+
36
+ # Ready to hand to an agent: approved and everything it builds on is merged, so the agent
37
+ # starts from a branch that already contains it.
38
+ def ready?
39
+ status == "approved" && dependency_tickets.all? { |ticket| ticket.status == "merged" }
40
+ end
41
+
42
+ def blocked_by
43
+ dependency_tickets.reject { |ticket| ticket.status == "merged" }.map(&:key)
44
+ end
45
+
46
+ def active_agent?
47
+ status == "running"
48
+ end
49
+
50
+ def feature_tag
51
+ "@#{plan.key.downcase}-#{key.downcase}"
52
+ end
53
+
54
+ def pr_approved_by
55
+ approvals.where(role: "pr", decision: "approved").order(:created_at).last&.actor
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "models/record"
4
+ require_relative "models/plan"
5
+ require_relative "models/ticket"
6
+ require_relative "models/approval"
7
+ require_relative "models/event"
8
+ require_relative "models/evidence_run"
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanDriven
4
+ class Railtie < Rails::Railtie
5
+ generators do
6
+ require_relative "../generators/plan_driven/install_generator"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanDriven
4
+ module Renderer
5
+ # A small Markdown-to-HTML converter for the documents this gem writes: headings, paragraphs,
6
+ # lists, tables, code blocks, emphasis and links. It isn't a general Markdown implementation,
7
+ # and doesn't need to be.
8
+ module HTML
9
+ STYLE = File.read(File.expand_path("style.css", __dir__))
10
+
11
+ module_function
12
+
13
+ def document(markdown, title:)
14
+ <<~HTML
15
+ <!doctype html>
16
+ <html lang="en">
17
+ <head>
18
+ <meta charset="utf-8">
19
+ <title>#{CGI.escapeHTML(title)}</title>
20
+ <style>#{STYLE}</style>
21
+ </head>
22
+ <body>
23
+ <main>
24
+ #{convert(markdown)}
25
+ </main>
26
+ </body>
27
+ </html>
28
+ HTML
29
+ end
30
+
31
+ def convert(markdown)
32
+ lines = markdown.to_s.lines.map(&:chomp)
33
+ out = []
34
+ until lines.empty?
35
+ line = lines.first
36
+ if line.start_with?("```")
37
+ out << code_block(lines)
38
+ elsif line.match?(/\A\s*\|/)
39
+ out << table(take_while(lines) { |l| l.match?(/\A\s*\|/) })
40
+ elsif (match = line.match(/\A(\#{1,6})\s+(.*)\z/))
41
+ lines.shift
42
+ level = match[1].size
43
+ out << "<h#{level}>#{inline(match[2])}</h#{level}>"
44
+ elsif line.match?(/\A\s*([-*]|\d+\.)\s+/)
45
+ out << list(take_while(lines) { |l| l.match?(/\A\s*([-*]|\d+\.)\s+/) || l.match?(/\A\s{2,}\S/) })
46
+ elsif line.strip.empty?
47
+ lines.shift
48
+ else
49
+ paragraph = take_while(lines) { |l| !l.strip.empty? && !l.match?(/\A(#|```|\s*\||\s*([-*]|\d+\.)\s)/) }
50
+ paragraph = [lines.shift] if paragraph.empty?
51
+ out << "<p>#{inline(paragraph.join(" "))}</p>"
52
+ end
53
+ end
54
+ out.join("\n")
55
+ end
56
+
57
+ def take_while(lines)
58
+ taken = []
59
+ taken << lines.shift while lines.any? && yield(lines.first)
60
+ taken
61
+ end
62
+
63
+ def code_block(lines)
64
+ lines.shift
65
+ body = take_while(lines) { |line| !line.start_with?("```") }
66
+ lines.shift
67
+ "<pre><code>#{CGI.escapeHTML(body.join("\n"))}</code></pre>"
68
+ end
69
+
70
+ def table(rows)
71
+ cells = rows.map { |row| row.strip.delete_prefix("|").delete_suffix("|").split(/(?<!\\)\|/).map(&:strip) }
72
+ header, *body = cells
73
+ body = body.reject { |row| row.all? { |cell| cell.match?(/\A:?-+:?\z/) } }
74
+ head = header.map { |cell| "<th>#{inline(cell)}</th>" }.join
75
+ rows = body.map { |row| "<tr>#{row.map { |cell| "<td>#{inline(cell.gsub("\\|", "|"))}</td>" }.join}</tr>" }
76
+ "<table><thead><tr>#{head}</tr></thead><tbody>#{rows.join}</tbody></table>"
77
+ end
78
+
79
+ def list(lines)
80
+ ordered = lines.first.match?(/\A\s*\d+\./)
81
+ items = []
82
+ lines.each do |line|
83
+ if line.match?(/\A\s*([-*]|\d+\.)\s+/)
84
+ items << line.sub(/\A\s*([-*]|\d+\.)\s+/, "")
85
+ else
86
+ items[-1] = "#{items.last} #{line.strip}"
87
+ end
88
+ end
89
+ tag = ordered ? "ol" : "ul"
90
+ "<#{tag}>#{items.map { |item| "<li>#{inline(item)}</li>" }.join}</#{tag}>"
91
+ end
92
+
93
+ def inline(text)
94
+ codes = []
95
+ escaped = CGI.escapeHTML(text.to_s).gsub(/`([^`]+)`/) do
96
+ codes << Regexp.last_match(1)
97
+ "\u0000#{codes.size - 1}\u0000"
98
+ end
99
+ escaped = escaped.gsub(/\*\*(.+?)\*\*/, '<strong>\1</strong>')
100
+ .gsub(/(?<![*\w])\*(?!\s)(.+?)(?<!\s)\*(?![*\w])/, '<em>\1</em>')
101
+ .gsub(/\[([^\]]+)\]\((https?:[^)\s]+|[^)\s]+\.(?:md|html|pdf))\)/, '<a href="\2">\1</a>')
102
+ escaped.gsub(/\u0000(\d+)\u0000/) { "<code>#{codes[Regexp.last_match(1).to_i]}</code>" }
103
+ end
104
+ end
105
+ end
106
+ end