pinmark 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.
@@ -0,0 +1,14 @@
1
+ <%
2
+ enabled = Pinmark.tracker.present?
3
+ css_class = enabled ? "pinmark-activator is-on" : "pinmark-activator"
4
+ label = enabled ? "Disable annotations" : "Enable annotations"
5
+ onclick = "var on=document.cookie.split('; ').some(function(c){return c.indexOf('pinmark=1')===0;});" \
6
+ "document.cookie=on?'pinmark=; path=/; max-age=0':'pinmark=1; path=/; max-age=2592000';" \
7
+ "window.location.assign(window.location.pathname+window.location.search);return false;"
8
+ %>
9
+ <button type="button"
10
+ id="pinmark-activator"
11
+ class="<%= css_class %>"
12
+ data-turbo="false"
13
+ onclick="<%= onclick.html_safe %>"><%= label %></button>
14
+ <style><%= Pinmark::Stylesheets::ACTIVATOR.html_safe %></style>
@@ -0,0 +1,24 @@
1
+ <% tracker = Pinmark.tracker %>
2
+ <% if tracker %>
3
+ <div data-controller="pinmark">
4
+ <script type="application/json" id="pinmark-tree"><%= ERB::Util.json_escape(tracker.tree.to_json).html_safe %></script>
5
+ <button type="button" data-action="click->pinmark#toggle" class="pinmark-toggle">Annotate</button>
6
+ <button type="button" data-action="click->pinmark#cycleTargetMode" data-pinmark-target="modeButton" class="pinmark-mode">Mode: Component</button>
7
+ <div class="pinmark-panel" data-pinmark-target="panel"></div>
8
+ <template data-pinmark-target="popoverTemplate">
9
+ <div class="pinmark-popover">
10
+ <div class="pinmark-popover-header" data-pinmark-target="popoverHeader"></div>
11
+ <textarea data-pinmark-target="popoverInput" placeholder="Comment…"></textarea>
12
+ <details class="pinmark-popover-debug">
13
+ <summary>Debug</summary>
14
+ <div data-pinmark-target="popoverDebug"></div>
15
+ </details>
16
+ <div class="pinmark-popover-actions">
17
+ <button type="button" data-action="click->pinmark#savePopover">Save</button>
18
+ <button type="button" data-action="click->pinmark#cancelPopover">Cancel</button>
19
+ </div>
20
+ </div>
21
+ </template>
22
+ <style><%= Pinmark::Stylesheets::OVERLAY.html_safe %></style>
23
+ </div>
24
+ <% end %>
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Engine-level importmap entries. Auto-merged by importmap-rails into the
4
+ # host app's importmap, so consumers do not need to add anything by hand.
5
+ pin "pinmark/pinmark_controller",
6
+ to: "pinmark/pinmark_controller.js"
data/config/routes.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ Pinmark::Engine.routes.draw do
4
+ if Rails.env.local?
5
+ resources :annotations, only: [:index, :create, :destroy]
6
+
7
+ # MCP server mounted in-process so Claude Code can speak MCP directly
8
+ # to the host Rails app. No separate Foreman/Overmind worker required.
9
+ mount Pinmark::Mcp::RackApp.new, at: "annotations/mcp"
10
+ end
11
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module Pinmark
6
+ module Generators
7
+ class InstallGenerator < ::Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ desc "Wire Pinmark into the host app: routes, importmap, layout reminders."
11
+
12
+ def mount_engine
13
+ route 'mount Pinmark::Engine, at: "/dev/pinmark" if Rails.env.local?'
14
+ end
15
+
16
+ def add_importmap_pin
17
+ return unless File.exist?("config/importmap.rb")
18
+
19
+ append_to_file "config/importmap.rb" do
20
+ <<~RB
21
+
22
+ # pinmark engine — Stimulus controller for the dev annotation overlay
23
+ pin "controllers/pinmark_controller", to: "pinmark/pinmark_controller.js"
24
+ RB
25
+ end
26
+ end
27
+
28
+ def show_post_install_notes
29
+ say "\nPinmark installed.", :green
30
+ say <<~NOTES
31
+
32
+ Next steps (manual):
33
+
34
+ 1. If you use Phlex, include the concern in your component base class
35
+ so `<!-- pinmark:begin/end -->` markers wrap each render:
36
+
37
+ class Components::Base < Phlex::HTML
38
+ include Pinmark::Phlex if Rails.env.development?
39
+ end
40
+
41
+ 2. Render the activator + overlay partials in your dev-only layout
42
+ sections (works in any Rails view — ERB, Phlex, or ViewComponent):
43
+
44
+ <%= render "pinmark/activator" %>
45
+ <%= render "pinmark/overlay" %>
46
+
47
+ Wrap with the usual dev-only / tracker guard if desired:
48
+
49
+ if Rails.env.development? && Current.pinmark.present?
50
+ render "pinmark/activator"
51
+ render "pinmark/overlay"
52
+ end
53
+
54
+ 3. Add `attribute :pinmark` to your `Current` model and include
55
+ `Pinmark::Session` in the controllers whose responses should
56
+ support annotations (typically StorefrontController).
57
+
58
+ 4. Register the in-process MCP server with Claude Code:
59
+
60
+ claude mcp add pinmark --transport http \\
61
+ http://localhost:PORT/dev/pinmark/annotations/mcp
62
+
63
+ 5. Webpack/yarn-based hosts (no importmap): add the engine as a
64
+ `file:` dependency in your host's package.json, then import the
65
+ Stimulus controller directly from the gem — do NOT copy the JS
66
+ into the host. Example:
67
+
68
+ # package.json
69
+ "pinmark": "file:#{Pinmark::Engine.root}"
70
+
71
+ # app/javascript/packs/your_pack.js
72
+ import PinmarkController from 'pinmark/pinmark_controller'
73
+ window.Stimulus.register('pinmark', PinmarkController)
74
+
75
+ Run `yarn install` after adding the dependency. The engine is the
76
+ single source of truth; updates flow through the symlink.
77
+
78
+ NOTES
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module Pinmark
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace Pinmark
8
+
9
+ initializer "pinmark.hooks" do
10
+ ActiveSupport.on_load(:action_view) do
11
+ require "pinmark/hooks/erb_partial"
12
+ ActionView::PartialRenderer.prepend(Pinmark::Hooks::ErbPartial)
13
+ end
14
+
15
+ ActiveSupport.on_load(:view_component) do
16
+ require "pinmark/hooks/view_component"
17
+ ViewComponent::Base.prepend(Pinmark::Hooks::ViewComponent)
18
+ end
19
+ end
20
+
21
+ # Make the engine's Stimulus controller available to host apps that use
22
+ # `pin_all_from "app/javascript/controllers"`. We expose it under
23
+ # `pinmark/` so the host can pin it explicitly without naming collisions.
24
+ initializer "pinmark.assets" do |app|
25
+ next unless app.config.respond_to?(:assets)
26
+
27
+ app.config.assets.paths << root.join("app/javascript")
28
+ end
29
+
30
+ # Importmap-rails integration: register the engine's JS so importmap can
31
+ # serve it under `pinmark/...`. The host pins it.
32
+ initializer "pinmark.importmap", before: "importmap" do |app|
33
+ next unless defined?(Importmap)
34
+
35
+ app.config.importmap.paths << root.join("config/importmap.rb") if app.config.respond_to?(:importmap)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinmark
4
+ module Hooks
5
+ # Prepended into ActionView::PartialRenderer.
6
+ #
7
+ # Rails 8.1 `render_partial_template(view, locals, template, layout, block)`
8
+ # is private and returns an `ActionView::AbstractRenderer::RenderedTemplate`
9
+ # whose `body` is the rendered HTML string. To preserve the contract for
10
+ # callers (collection assembly, layout wrapping) the override re-wraps the
11
+ # body and returns a fresh RenderedTemplate.
12
+ module ErbPartial
13
+ def render_partial_template(view, locals, template, layout, block)
14
+ rendered = super
15
+ return rendered unless Pinmark.active?
16
+ return rendered unless rendered.respond_to?(:body) && rendered.respond_to?(:template)
17
+
18
+ identifier = template.respond_to?(:identifier) ? template.identifier.to_s : template.to_s
19
+ relative = relative_to_root(identifier)
20
+ component = "partial:#{File.basename(relative)}"
21
+ source = "#{relative}:1"
22
+
23
+ wrapped_body = Pinmark::Wrapper.wrap(component:, source:) do
24
+ rendered.body.to_s
25
+ end
26
+
27
+ ::ActionView::AbstractRenderer::RenderedTemplate.new(wrapped_body, rendered.template)
28
+ end
29
+
30
+ private
31
+
32
+ def relative_to_root(identifier)
33
+ Pathname.new(identifier).relative_path_from(Rails.root).to_s
34
+ rescue ArgumentError
35
+ identifier
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinmark
4
+ module Hooks
5
+ # Prepended into ViewComponent::Base when the gem is present. Inert until
6
+ # then. Mirrors the Phlex `Components::Base#around_template` hook by wrapping
7
+ # `render_in`'s output in pinmark markers.
8
+ module ViewComponent
9
+ def render_in(view_context, &)
10
+ return super unless Pinmark.active?
11
+
12
+ component = self.class.name || "anonymous"
13
+ source = Pinmark::SourceLocator.for(self.class) || "unknown"
14
+
15
+ Pinmark::Wrapper.wrap(component:, source:) do
16
+ super
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "pathname"
6
+
7
+ module Pinmark
8
+ module Mcp
9
+ # Persists the pinmark queue file with atomic writes so concurrent
10
+ # readers (the Rails endpoint and the MCP tools) never see a half-written
11
+ # JSON document.
12
+ class Queue
13
+ attr_reader :path
14
+
15
+ def self.default_path
16
+ Rails.root.join("tmp/pinmark/queue.json")
17
+ end
18
+
19
+ def initialize(path = nil)
20
+ @path = Pathname.new(path || self.class.default_path).expand_path
21
+ ensure_file
22
+ end
23
+
24
+ def ensure_file
25
+ FileUtils.mkdir_p(@path.dirname)
26
+ return if @path.exist?
27
+
28
+ write({ "annotations" => [] })
29
+ end
30
+
31
+ def read
32
+ ensure_file
33
+ parsed = JSON.parse(@path.read)
34
+ parsed["annotations"] = [] unless parsed["annotations"].is_a?(Array)
35
+ parsed
36
+ rescue JSON::ParserError
37
+ { "annotations" => [] }
38
+ end
39
+
40
+ def write(data)
41
+ tmp = Pathname.new("#{@path}.tmp")
42
+ tmp.write(JSON.pretty_generate(data))
43
+ File.rename(tmp, @path)
44
+ end
45
+
46
+ def pending
47
+ read.fetch("annotations", []).select { |entry| entry["status"] == "pending" }
48
+ end
49
+
50
+ def addressed
51
+ read.fetch("annotations", []).select { |entry| entry["status"] == "addressed" }
52
+ end
53
+
54
+ def append(entries)
55
+ data = read
56
+ data["annotations"] = data.fetch("annotations", []) + Array(entries)
57
+ write(data)
58
+ data["annotations"]
59
+ end
60
+
61
+ def mark_addressed(id)
62
+ data = read
63
+ found = false
64
+ already_addressed = false
65
+
66
+ data["annotations"] = data.fetch("annotations", []).map do |entry|
67
+ next entry unless entry["id"] == id
68
+
69
+ found = true
70
+ already_addressed = true if entry["status"] == "addressed"
71
+ entry.merge("status" => "addressed", "addressed_at" => Time.now.utc.iso8601)
72
+ end
73
+
74
+ return { found: false } unless found
75
+
76
+ write(data)
77
+ { found: true, already_addressed: }
78
+ end
79
+
80
+ def clear_addressed
81
+ data = read
82
+ before = data.fetch("annotations", []).size
83
+ data["annotations"] = data.fetch("annotations", []).reject { |entry| entry["status"] == "addressed" }
84
+ removed = before - data["annotations"].size
85
+ write(data)
86
+ { removed:, remaining: data["annotations"].size }
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+ require "mcp/server/transports/streamable_http_transport"
5
+
6
+ module Pinmark
7
+ module Mcp
8
+ # Rack-mountable adapter that exposes the pinmark server over the
9
+ # official `mcp` gem's Streamable HTTP transport. Designed to be
10
+ # mounted inside a Rails router (or any Rack stack), so the standalone
11
+ # Puma process the gem ships is unnecessary.
12
+ #
13
+ # This class deliberately avoids any Rails-app-specific references
14
+ # (controllers, Current attributes, etc.) so the entire Pinmark::Mcp
15
+ # namespace can later be extracted as a Rails engine / standalone gem.
16
+ class RackApp
17
+ # Run the transport in stateless mode: every request stands on its own,
18
+ # no per-session state is held in memory. This makes the mount
19
+ # resilient to Rails' dev-mode code reloading (which can rebuild the
20
+ # mounted Rack instance) and keeps the in-process server cheap to
21
+ # operate. The annotation tools are simple request/response —
22
+ # they do not need streaming notifications.
23
+ def initialize(queue: Queue.new)
24
+ @queue = queue
25
+ @server = Server.build(queue: @queue)
26
+ @transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(
27
+ @server,
28
+ stateless: true,
29
+ enable_json_response: true
30
+ )
31
+ end
32
+
33
+ delegate :call, to: :@transport
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+
5
+ module Pinmark
6
+ module Mcp
7
+ # Builds an MCP::Server instance with the annotation tools registered.
8
+ # The server is configured via a Queue instance passed through
9
+ # server_context so tools share a single atomic on-disk queue.
10
+ class Server
11
+ NAME = "pinmark"
12
+ VERSION = "0.1.0"
13
+
14
+ def self.build(queue:)
15
+ ::MCP::Server.new(
16
+ name: NAME,
17
+ version: VERSION,
18
+ tools: [
19
+ Tools::ListPending,
20
+ Tools::ListResolved,
21
+ Tools::MarkAddressed,
22
+ Tools::ClearAddressed
23
+ ],
24
+ server_context: { queue: }
25
+ )
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+
5
+ module Pinmark
6
+ module Mcp
7
+ module Tools
8
+ class ClearAddressed < ::MCP::Tool
9
+ tool_name "clear_addressed"
10
+ description "Drop every entry whose status='addressed' from the queue file. Returns the " \
11
+ "number removed."
12
+ input_schema(properties: {})
13
+
14
+ class << self
15
+ def call(server_context:)
16
+ queue = server_context.fetch(:queue)
17
+ result = queue.clear_addressed
18
+ Tools.text_response({
19
+ "ok" => true,
20
+ "removed" => result[:removed],
21
+ "remaining" => result[:remaining]
22
+ })
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "mcp"
5
+
6
+ module Pinmark
7
+ module Mcp
8
+ module Tools
9
+ def self.text_response(payload)
10
+ ::MCP::Tool::Response.new([{ type: "text", text: JSON.pretty_generate(payload) }])
11
+ end
12
+
13
+ class ListPending < ::MCP::Tool
14
+ tool_name "list_pending_annotations"
15
+ description "Return every annotation in the queue with status='pending'. Each entry " \
16
+ "includes the source file:line, component class, page path, relative DOM " \
17
+ "selector (if any), the user's comment, and the entry id needed for " \
18
+ "mark_addressed."
19
+ input_schema(properties: {})
20
+
21
+ class << self
22
+ def call(server_context:)
23
+ queue = server_context.fetch(:queue)
24
+ pending = queue.pending.map do |entry|
25
+ {
26
+ "id" => entry["id"],
27
+ "node_id" => entry["node_id"],
28
+ "component" => entry["component"],
29
+ "source" => entry["source"],
30
+ "selector" => entry["selector"],
31
+ "text_excerpt" => entry["text_excerpt"],
32
+ "comment" => entry["comment"],
33
+ "page_path" => entry["page_path"],
34
+ "captured_at" => entry["captured_at"],
35
+ "ancestry" => entry["ancestry"]
36
+ }
37
+ end
38
+ Tools.text_response({ "count" => pending.size, "annotations" => pending })
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "mcp"
5
+
6
+ module Pinmark
7
+ module Mcp
8
+ module Tools
9
+ class ListResolved < ::MCP::Tool
10
+ tool_name "list_resolved_annotations"
11
+ description "Return every annotation in the queue with status='addressed'. Use this " \
12
+ "to audit annotations that have already been handled, e.g. for a " \
13
+ "post-pass review or to undo. Same shape as list_pending_annotations, " \
14
+ "plus addressed_at."
15
+ input_schema(properties: {})
16
+
17
+ class << self
18
+ def call(server_context:)
19
+ queue = server_context.fetch(:queue)
20
+ resolved = queue.addressed.map do |entry|
21
+ {
22
+ "id" => entry["id"],
23
+ "node_id" => entry["node_id"],
24
+ "component" => entry["component"],
25
+ "source" => entry["source"],
26
+ "selector" => entry["selector"],
27
+ "text_excerpt" => entry["text_excerpt"],
28
+ "comment" => entry["comment"],
29
+ "page_path" => entry["page_path"],
30
+ "captured_at" => entry["captured_at"],
31
+ "addressed_at" => entry["addressed_at"],
32
+ "ancestry" => entry["ancestry"]
33
+ }
34
+ end
35
+ Tools.text_response({ "count" => resolved.size, "annotations" => resolved })
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+
5
+ module Pinmark
6
+ module Mcp
7
+ module Tools
8
+ class MarkAddressed < ::MCP::Tool
9
+ tool_name "mark_addressed"
10
+ description "Mark a single annotation as addressed by id. Idempotent: calling on an " \
11
+ "already-addressed id is a no-op."
12
+ input_schema(
13
+ properties: {
14
+ id: {
15
+ type: "string",
16
+ description: "Annotation id returned by list_pending_annotations"
17
+ }
18
+ },
19
+ required: ["id"]
20
+ )
21
+
22
+ class << self
23
+ def call(id:, server_context:)
24
+ queue = server_context.fetch(:queue)
25
+ result = queue.mark_addressed(id)
26
+
27
+ unless result[:found]
28
+ return Tools.text_response({ "ok" => false, "error" => "No annotation with id=#{id}" })
29
+ end
30
+
31
+ Tools.text_response({
32
+ "ok" => true,
33
+ "id" => id,
34
+ "already_addressed" => result[:already_addressed]
35
+ })
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "cgi"
5
+
6
+ module Pinmark
7
+ # Concern intended to be `include`d into the host application's Phlex
8
+ # component base class (typically `Components::Base`). Provides an
9
+ # `around_template` override that pushes/pops the per-request tracker and
10
+ # emits `<!-- pinmark:begin/end -->` markers around the component's
11
+ # rendered HTML.
12
+ #
13
+ # Inert outside of development mode and when no tracker is set on the
14
+ # current request, so it is safe to include unconditionally.
15
+ module Phlex
16
+ extend ActiveSupport::Concern
17
+
18
+ included do
19
+ attr_reader :pinmark_id
20
+
21
+ def around_template(&)
22
+ @pinmark_id = nil
23
+ tracker = Rails.env.development? ? Pinmark.tracker : nil
24
+ return super unless tracker
25
+
26
+ source = Pinmark::SourceLocator.for(self.class) || "unknown"
27
+ component_name = self.class.name || "anonymous"
28
+ @pinmark_id = tracker.push(component: component_name, source:)
29
+ parent_id = tracker.nodes.last[:parent_id]
30
+
31
+ id_attr = Pinmark::Wrapper.escape(@pinmark_id)
32
+ class_attr = Pinmark::Wrapper.escape(component_name)
33
+ src_attr = Pinmark::Wrapper.escape(source)
34
+ parent_attr = Pinmark::Wrapper.escape(parent_id)
35
+
36
+ raw safe(%(<!-- pinmark:begin id="#{id_attr}" class="#{class_attr}" src="#{src_attr}" parent="#{parent_attr}" -->))
37
+ super
38
+ raw safe(%(<!-- pinmark:end id="#{id_attr}" -->))
39
+ ensure
40
+ tracker&.pop if @pinmark_id
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinmark
4
+ module SourceLocator
5
+ module_function
6
+
7
+ def for(klass)
8
+ return nil unless klass.name
9
+
10
+ file, line = Module.const_source_location(klass.name)
11
+ return nil unless file
12
+
13
+ relative = Pathname.new(file).relative_path_from(Rails.root)
14
+ "#{relative}:#{line}"
15
+ end
16
+ end
17
+ end