lookbook 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +257 -0
  4. data/app/assets/lookbook/css/app.css +37 -0
  5. data/app/assets/lookbook/css/code_theme.css +214 -0
  6. data/app/assets/lookbook/css/tooltip_theme.css +16 -0
  7. data/app/assets/lookbook/js/app.js +47 -0
  8. data/app/assets/lookbook/js/preview.js +76 -0
  9. data/app/assets/lookbook/js/reloader.js +28 -0
  10. data/app/assets/lookbook/js/size_observer.js +16 -0
  11. data/app/assets/lookbook/js/split.js +26 -0
  12. data/app/channels/lookbook/connection.rb +11 -0
  13. data/app/channels/lookbook/reload_channel.rb +7 -0
  14. data/app/controllers/lookbook/browser_controller.rb +141 -0
  15. data/app/helpers/lookbook/application_helper.rb +24 -0
  16. data/app/views/lookbook/browser/error.html.erb +1 -0
  17. data/app/views/lookbook/browser/index.html.erb +8 -0
  18. data/app/views/lookbook/browser/not_found.html.erb +25 -0
  19. data/app/views/lookbook/browser/show.html.erb +33 -0
  20. data/app/views/lookbook/layouts/app.html.erb +49 -0
  21. data/app/views/lookbook/partials/_icon_sprite.html.erb +1 -0
  22. data/app/views/lookbook/partials/_preview.html.erb +18 -0
  23. data/app/views/lookbook/partials/_sidebar.html.erb +21 -0
  24. data/app/views/lookbook/partials/inspector/_code.html.erb +1 -0
  25. data/app/views/lookbook/partials/inspector/_inspector.html.erb +43 -0
  26. data/app/views/lookbook/partials/inspector/_plain.html.erb +3 -0
  27. data/app/views/lookbook/partials/inspector/_prose.html.erb +3 -0
  28. data/app/views/lookbook/partials/nav/_collection.html.erb +17 -0
  29. data/app/views/lookbook/partials/nav/_label.html.erb +13 -0
  30. data/app/views/lookbook/partials/nav/_nav.html.erb +27 -0
  31. data/app/views/lookbook/partials/nav/_preview.html.erb +48 -0
  32. data/config/lookbook_cable.yml +8 -0
  33. data/config/routes.rb +8 -0
  34. data/lib/lookbook.rb +14 -0
  35. data/lib/lookbook/collection.rb +51 -0
  36. data/lib/lookbook/engine.rb +96 -0
  37. data/lib/lookbook/lang.rb +42 -0
  38. data/lib/lookbook/navigation.rb +68 -0
  39. data/lib/lookbook/parser.rb +33 -0
  40. data/lib/lookbook/preview.rb +86 -0
  41. data/lib/lookbook/preview_controller.rb +17 -0
  42. data/lib/lookbook/preview_example.rb +68 -0
  43. data/lib/lookbook/taggable.rb +23 -0
  44. data/lib/lookbook/version.rb +3 -0
  45. data/lib/tasks/lookbook_tasks.rake +15 -0
  46. data/public/lookbook-assets/app.css +2361 -0
  47. data/public/lookbook-assets/app.js +7692 -0
  48. metadata +242 -0
@@ -0,0 +1,16 @@
1
+ .tippy-box[data-theme~="lookbook"] {
2
+ @apply bg-indigo-500 text-white text-xs opacity-90;
3
+
4
+ &[data-placement^="top"] > .tippy-arrow::before {
5
+ border-top-color: theme("colors.indigo.500");
6
+ }
7
+ &[data-placement^="bottom"] > .tippy-arrow::before {
8
+ border-bottom-color: theme("colors.indigo.500");
9
+ }
10
+ &[data-placement^="left"] > .tippy-arrow::before {
11
+ border-left-color: theme("colors.indigo.500");
12
+ }
13
+ &[data-placement^="right"] > .tippy-arrow::before {
14
+ border-right-color: theme("colors.indigo.500");
15
+ }
16
+ }
@@ -0,0 +1,47 @@
1
+ import Alpine from "alpinejs";
2
+ import Fern from "@ryangjchandler/fern";
3
+ import Tooltip from "@ryangjchandler/alpine-tooltip";
4
+ import Clipboard from "@ryangjchandler/alpine-clipboard";
5
+ import split from "./split";
6
+ import preview from "./preview";
7
+ import observeSize from "./size_observer";
8
+ import reloader from "./reloader";
9
+
10
+ // Plugins
11
+
12
+ Alpine.plugin(Fern);
13
+ Alpine.plugin(Tooltip);
14
+ Alpine.plugin(Clipboard);
15
+
16
+ // Data
17
+
18
+ Alpine.data("preview", preview);
19
+ Alpine.data("sizeObserver", observeSize);
20
+ Alpine.data("split", split);
21
+
22
+ // Stores
23
+
24
+ Alpine.store("app", { reflowing: false });
25
+ Alpine.persistedStore("nav", {
26
+ width: 280,
27
+ filter: "",
28
+ open: {},
29
+ scrollTop: 0,
30
+ shouldDisplay(previewName) {
31
+ const cleanFilter = this.filter.replace(/\s/g, "");
32
+ return (
33
+ cleanFilter === "" || previewName.includes(cleanFilter.toLowerCase())
34
+ );
35
+ },
36
+ });
37
+ Alpine.persistedStore("preview", {});
38
+ Alpine.persistedStore("inspector", {
39
+ height: 200,
40
+ active: "source",
41
+ });
42
+
43
+ // Init
44
+
45
+ window.Alpine = Alpine;
46
+ reloader(window.SOCKET_PATH).start();
47
+ Alpine.start();
@@ -0,0 +1,76 @@
1
+ export default function preview() {
2
+ const app = Alpine.store("app");
3
+ const preview = Alpine.store("preview");
4
+ return {
5
+ init() {
6
+ this.root = this.$el;
7
+ },
8
+ onResize(e) {
9
+ const size =
10
+ this.resizeStartSize - (this.resizeStartPosition - e.pageX) * 2;
11
+ const parentSize = this.root.parentElement.clientWidth;
12
+ const percentSize = (Math.round(size) / parentSize) * 100;
13
+ const minWidth = (300 / parentSize) * 100;
14
+ preview.width = `${Math.min(Math.max(percentSize, minWidth), 100)}%`;
15
+ },
16
+ onResizeStart(e) {
17
+ app.reflowing = true;
18
+ this.onResize = this.onResize.bind(this);
19
+ this.onResizeEnd = this.onResizeEnd.bind(this);
20
+ this.resizeStartPosition = e.pageX;
21
+ this.resizeStartSize = this.root.clientWidth;
22
+ window.addEventListener("pointermove", this.onResize);
23
+ window.addEventListener("pointerup", this.onResizeEnd);
24
+ },
25
+ onResizeEnd(e) {
26
+ window.removeEventListener("pointermove", this.onResize);
27
+ window.removeEventListener("pointerup", this.onResizeEnd);
28
+ app.reflowing = false;
29
+ },
30
+ handle: {
31
+ ["@pointerdown"]: "onResizeStart",
32
+ ["@dblclick"]() {
33
+ if (preview.width === "100%" && preview.lastWidth) {
34
+ preview.width = preview.lastWidth;
35
+ } else {
36
+ preview.lastWidth = preview.width;
37
+ preview.width = "100%";
38
+ }
39
+ },
40
+ },
41
+ };
42
+ }
43
+
44
+ // export default function (dimension, store, { shrink = false, centered = false } = {}) {
45
+ // const position = (e) => (dimension == "height" ? e.pageY : e.pageX);
46
+ // const pane = {
47
+ // onResize(e) {
48
+ // let size =
49
+ // this.resizeStartSize -
50
+ // (shrink
51
+ // ? (this.resizeStartPosition - position(e)) * (centered ? 2 : 1)
52
+ // : (position(e) - this.resizeStartPosition) * (centered ? 2 : 1));
53
+ // const parentSize =
54
+ // dimension == "height"
55
+ // ? this.$el.parentElement.clientHeight
56
+ // : this.$el.parentElement.clientWidth;
57
+ // const percentSize = (Math.round(size) / parentSize) * 100;
58
+ // store[dimension] = `${Math.min(Math.max(percentSize, 0), 100)}%`;
59
+ // },
60
+ // onResizeStart(e) {
61
+ // Spruce.store("app").reflowing = true;
62
+ // this.resizeStartPosition = position(e);
63
+ // this.resizeStartSize = dimension == "height" ? this.$el.clientHeight : this.$el.clientWidth;
64
+ // this.onResize = this.onResize.bind(this);
65
+ // this.onResizeEnd = this.onResizeEnd.bind(this);
66
+ // window.addEventListener("pointermove", this.onResize);
67
+ // window.addEventListener("pointerup", this.onResizeEnd);
68
+ // },
69
+ // onResizeEnd() {
70
+ // Spruce.store("app").reflowing = false;
71
+ // window.removeEventListener("pointermove", this.onResize);
72
+ // window.removeEventListener("pointerup", this.onResizeEnd);
73
+ // },
74
+ // };
75
+ // return pane;
76
+ // };
@@ -0,0 +1,28 @@
1
+ import { createConsumer } from "@rails/actioncable";
2
+ import debounce from "debounce";
3
+
4
+ export default function (endpoint) {
5
+ const uid = (Date.now() + ((Math.random() * 100) | 0)).toString();
6
+ const consumer = createConsumer(`${endpoint}?uid=${uid}`);
7
+
8
+ return {
9
+ uid,
10
+ consumer,
11
+ start() {
12
+ const received = debounce(() => {
13
+ console.log("Lookbook files changed");
14
+ document.dispatchEvent(new CustomEvent("refresh"));
15
+ }, 300);
16
+
17
+ consumer.subscriptions.create("Lookbook::ReloadChannel", {
18
+ received,
19
+ connected() {
20
+ console.log("Lookbook websocket connected");
21
+ },
22
+ disconnected() {
23
+ console.log("Lookbook websocket disconnected");
24
+ },
25
+ });
26
+ },
27
+ };
28
+ }
@@ -0,0 +1,16 @@
1
+ export default function () {
2
+ return {
3
+ observedWidth: 0,
4
+ observedHeight: 0,
5
+ init() {
6
+ const ro = new ResizeObserver((entries) => {
7
+ const rect = entries[0].contentRect;
8
+ this.observedWidth = Math.round(rect.width);
9
+ this.observedHeight = Math.round(rect.height);
10
+ });
11
+ ro.observe(this.$el);
12
+ this.observedWidth = Math.round(this.$el.clientWidth);
13
+ this.observedHeight = Math.round(this.$el.clientHeight);
14
+ },
15
+ };
16
+ }
@@ -0,0 +1,26 @@
1
+ import Split from "split-grid";
2
+
3
+ export default function (props) {
4
+ const app = Alpine.store("app");
5
+ return {
6
+ init() {
7
+ Split({
8
+ [`${props.direction === "vertical" ? "row" : "column"}Gutters`]: [
9
+ { track: 1, element: this.$el },
10
+ ],
11
+ minSize: props.minSize,
12
+ writeStyle() {},
13
+ onDrag(dir, track, style) {
14
+ splits = style.split(" ").map((num) => parseInt(num));
15
+ props.onDrag(splits);
16
+ },
17
+ onDragStart() {
18
+ app.reflowing = true;
19
+ },
20
+ onDragEnd() {
21
+ app.reflowing = false;
22
+ },
23
+ });
24
+ },
25
+ };
26
+ }
@@ -0,0 +1,11 @@
1
+ module Lookbook
2
+ class Connection < ActionCable::Connection::Base
3
+ identified_by :uid
4
+
5
+ def connect
6
+ self.uid = request.params[:uid]
7
+ logger.add_tags(uid)
8
+ logger.info "connected to Lookbook"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,7 @@
1
+ module Lookbook
2
+ class ReloadChannel < ActionCable::Channel::Base
3
+ def subscribed
4
+ stream_from "reload"
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,141 @@
1
+ module Lookbook
2
+ class BrowserController < ActionController::Base
3
+ EXCEPTIONS = [ViewComponent::PreviewTemplateError, ViewComponent::ComponentError, ViewComponent::TemplateError, ActionView::Template::Error]
4
+
5
+ protect_from_forgery with: :exception
6
+ prepend_view_path File.expand_path("../../views/lookbook", __dir__)
7
+
8
+ layout "layouts/app"
9
+ helper Lookbook::Engine.helpers
10
+
11
+ before_action :find_preview, only: [:preview, :show]
12
+ before_action :find_example, only: [:preview, :show]
13
+ before_action :assign_nav, only: [:index, :show]
14
+
15
+ def index
16
+ end
17
+
18
+ def preview
19
+ if @example
20
+ render html: preview_output
21
+ else
22
+ render "browser/not_found"
23
+ end
24
+ end
25
+
26
+ def show
27
+ if @example
28
+ begin
29
+ @preview_srcdoc = preview_output.gsub("\"", "&quot;")
30
+ @render_args = @preview.render_args(@example.name, params: preview_controller.params.permit!)
31
+ @render_output = preview_controller.render_component_to_string(@preview, @example_name)
32
+ @render_output_lang = Lookbook::Lang.find(:html)
33
+ if using_preview_template?
34
+ @source = @example.method_source
35
+ @source_lang = @example.source_lang
36
+ else
37
+ @source = @example.template_source(@render_args[:template])
38
+ @source_lang = @example.template_lang(@render_args[:template])
39
+ end
40
+ assign_inspector
41
+ rescue *EXCEPTIONS
42
+ render "browser/error"
43
+ end
44
+ else
45
+ render "browser/not_found"
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def find_preview
52
+ candidates = []
53
+ params[:path].to_s.scan(%r{/|$}) { candidates << $` }
54
+ match = candidates.detect { |candidate| Lookbook::Preview.exists?(candidate) }
55
+ @preview = match ? Lookbook::Preview.find(match) : nil
56
+ end
57
+
58
+ def find_example
59
+ @example = if @preview
60
+ if params[:path] == @preview.lookbook_path
61
+ redirect_to show_path "#{params[:path]}/#{@preview.lookbook_examples.first.name}"
62
+ else
63
+ @example_name = File.basename(params[:path])
64
+ @preview.lookbook_example(@example_name)
65
+ end
66
+ end
67
+ end
68
+
69
+ def using_preview_template?
70
+ @render_args[:template] == "view_components/preview"
71
+ end
72
+
73
+ def preview_output
74
+ @preview_output ||= if @preview
75
+ preview_controller.request.params[:path] = "#{@preview.preview_name}/#{@example.name}".chomp("/")
76
+ preview_controller.process(:previews)
77
+ end
78
+ end
79
+
80
+ def assign_inspector
81
+ @inspector = {
82
+ panes: {
83
+ source: {
84
+ label: "Source",
85
+ content: @source || "",
86
+ template: "code",
87
+ lang: @source_lang,
88
+ clipboard: @source
89
+ },
90
+ output: {
91
+ label: "Output",
92
+ content: @render_output || "",
93
+ template: "code",
94
+ lang: @render_output_lang,
95
+ clipboard: @render_output
96
+ },
97
+ notes: {
98
+ label: "Notes",
99
+ content: @example.notes.presence || "<em class='opacity-50'>No notes provided.</em>",
100
+ template: "prose",
101
+ disabled: @example.notes.blank?
102
+ }
103
+ }
104
+ }
105
+ end
106
+
107
+ def assign_nav
108
+ @nav = Collection.new
109
+ previews.reject { |p| p.hidden? }.each do |preview|
110
+ current = @nav
111
+ if preview.hierarchy_depth == 1
112
+ current.add(preview)
113
+ else
114
+ preview.lookbook_parent_collections.each.with_index(1) do |name, i|
115
+ target = current.get_or_create(name)
116
+ if preview.hierarchy_depth == i + 1
117
+ target.add(preview)
118
+ else
119
+ current = target
120
+ end
121
+ end
122
+ end
123
+ end
124
+ @nav
125
+ end
126
+
127
+ def previews
128
+ Lookbook::Preview.all
129
+ end
130
+
131
+ def preview_controller
132
+ return @preview_controller if @preview_controller.present?
133
+ controller_class = Lookbook.config.preview_controller.constantize
134
+ controller_class.class_eval { include Lookbook::PreviewController }
135
+ controller = controller_class.new
136
+ controller.request = request
137
+ controller.response = response
138
+ @preview_controller ||= controller
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,24 @@
1
+ require "redcarpet"
2
+ require "rouge"
3
+
4
+ module Lookbook
5
+ module ApplicationHelper
6
+ def config
7
+ Lookbook::Engine.config.lookbook
8
+ end
9
+
10
+ def markdown(text)
11
+ Markdown.new(text).to_html.html_safe
12
+ end
13
+
14
+ def highlight(source, language)
15
+ formatter = Rouge::Formatters::HTML.new(css_class: "highlight")
16
+ lexer = Rouge::Lexer.find(language)
17
+ formatter.format(lexer.lex(source)).html_safe
18
+ end
19
+
20
+ def nav_padding_style(depth)
21
+ "padding-left: calc((#{depth} * 12px) + 0.5rem);"
22
+ end
23
+ end
24
+ end
@@ -0,0 +1 @@
1
+ <iframe src="<%= url_for lookbook.preview_path %>" frameborder="0" class=" h-screen w-full" seamless></iframe>
@@ -0,0 +1,8 @@
1
+ <div class="flex flex-col items-center justify-center h-screen w-full">
2
+ <div class="p-4 text-center">
3
+ <svg class="feather w-10 h-10 text-gray-300 mx-auto">
4
+ <use xlink:href="#layers" />
5
+ </svg>
6
+ <h5 class="mt-4 text-gray-400 text-base">Select a preview to get started</h5>
7
+ </div>
8
+ </div>
@@ -0,0 +1,25 @@
1
+ <div class="bg-white flex flex-col items-center justify-center h-screen w-full">
2
+ <div class="p-4 text-center">
3
+ <svg class="feather w-10 h-10 text-red-300 mx-auto">
4
+ <use xlink:href="#alert-triangle" />
5
+ </svg>
6
+ <div class="mt-3 text-gray-700 max-w-xs">
7
+ <% if @preview %>
8
+ <div data-role="example-not-found">
9
+ <h5 class="text-base">Not found</h5>
10
+ <p class="mt-2 text-gray-400 text-sm">
11
+ The "<span class="text-gray-500"><%= @preview.lookbook_label %></span>" preview does not have an example named "<span class="text-gray-500"><%= @example_name %></span>".
12
+ </p>
13
+ </div>
14
+ <% else %>
15
+ <div data-role="preview-not-found">
16
+ <h5 class="text-base">Preview not found</h5>
17
+ <p class="mt-2 text-gray-400 text-sm">
18
+ Looked for "<span class="text-gray-500"><%= params[:path] %></span>".<br>
19
+ The preview may have been renamed or deleted.
20
+ </p>
21
+ </div>
22
+ <% end %>
23
+ </div>
24
+ </div>
25
+ </div>