actionview-svelte-handler 0.2.0 → 0.4.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,151 @@
1
+ require "active_support/syntax_error_proxy"
2
+ require "action_view/template/error"
3
+ module Svelte
4
+ class CompilerError < ActiveSupport::SyntaxErrorProxy # steep:ignore UnknownConstant
5
+ attr_accessor :location, :suggestion, :template
6
+
7
+ def initialize(message, location)
8
+ @location = location
9
+ @sugggestion = @location.dig(:suggestion) || ""
10
+ # steep:ignore:start
11
+ super(message) # : self
12
+ # steep:ignore:end
13
+ end
14
+
15
+ def message
16
+ to_s
17
+ end
18
+
19
+ def backtrace
20
+ if suggestion
21
+ ["#{Rails.root.join location[:file]}:#{location[:line]}:#{message}, #{suggestion}"] + caller
22
+ else
23
+ ["#{Rails.root.join location[:file]}:#{location[:line]}:#{message}"] + caller
24
+ end
25
+ end
26
+
27
+ def cause
28
+ nil
29
+ end
30
+
31
+ def bindings
32
+ [template.send(:binding)]
33
+ end
34
+
35
+ def backtrace_locations
36
+ traces = backtrace.map { |trace|
37
+ file, line = trace.match(/^(.+?):(\d+).*$/, &:captures) || trace
38
+ ActiveSupport::SyntaxErrorProxy::BacktraceLocation.new(file, line.to_i, trace) # steep:ignore UnknownConstant
39
+ }
40
+
41
+ traces.map { |loc| ActiveSupport::SyntaxErrorProxy::BacktraceLocationProxy.new(loc, self) } # steep:ignore UnknownConstant
42
+ end
43
+
44
+ def annotated_source_code
45
+ location[:lineText].split("\n").map.with_index(1) { |line, index|
46
+ indentation = " " * 4
47
+ "#{index}:#{indentation}#{line}"
48
+ }
49
+ end
50
+ end
51
+
52
+ class TemplateError < StandardError
53
+ attr_reader :cause, :template
54
+
55
+ SOURCE_CODE_RADIUS = 3
56
+
57
+ def initialize(template, error)
58
+ raise("Did not provide cause error") unless error
59
+ @cause = error
60
+
61
+ raise("Did not provide template") unless template
62
+ @template, @sub_templates = template, nil
63
+
64
+ raise("Cause error is nil for TemplateError") unless @cause
65
+ @cause.template = template
66
+
67
+ # steep:ignore:start
68
+ super(@cause.message) # : self
69
+ # steep:ignore:end
70
+ end
71
+
72
+ def message
73
+ @cause.message
74
+ end
75
+
76
+ def annotated_source_code
77
+ @cause.annotated_source_code
78
+ end
79
+
80
+ # Following is copypasta-ed from ActionView::Template::Error
81
+ def backtrace
82
+ @cause.backtrace
83
+ end
84
+
85
+ def backtrace_locations
86
+ @cause.backtrace_locations
87
+ end
88
+
89
+ def file_name
90
+ @template.identifier
91
+ end
92
+
93
+ def sub_template_message
94
+ if @sub_templates
95
+ "Trace of template inclusion: " +
96
+ @sub_templates.collect(&:inspect).join(", ")
97
+ else
98
+ ""
99
+ end
100
+ end
101
+
102
+ def source_extract(indentation = 0)
103
+ return [] unless (num = line_number)
104
+ num = num.to_i
105
+
106
+ source_code = @template.encode!.split("\n")
107
+
108
+ start_on_line = [num - SOURCE_CODE_RADIUS - 1, 0].max
109
+ end_on_line = [num + SOURCE_CODE_RADIUS - 1, source_code.length].min
110
+
111
+ indent = end_on_line.to_s.size + indentation
112
+ return [] unless (source_code = source_code[start_on_line..end_on_line])
113
+
114
+ formatted_code_for(source_code, start_on_line, indent)
115
+ end
116
+
117
+ def sub_template_of(template_path)
118
+ @sub_templates ||= []
119
+ @sub_templates << template_path
120
+ end
121
+
122
+ def line_number
123
+ @line_number ||=
124
+ if file_name
125
+ regexp = /#{Regexp.escape File.basename(file_name)}:(\d+)/
126
+ $1 if message =~ regexp || backtrace.find { |line| line =~ regexp }
127
+ end
128
+ end
129
+
130
+ private
131
+
132
+ def source_location
133
+ if line_number
134
+ "on line ##{line_number} of "
135
+ else
136
+ "in "
137
+ end + file_name
138
+ end
139
+
140
+ def formatted_code_for(source_code, line_counter, indent)
141
+ raise("line counter is nil") if line_counter.nil?
142
+ indent_template = "%#{indent}s: %s"
143
+ source_code.map do |line|
144
+ line_counter += 1
145
+ indent_template % [line_counter, line]
146
+ end
147
+ end
148
+ end
149
+ end
150
+
151
+ # {:id=>"", :location=>{:column=>13, :file=>"demo/e.html.svelte", :length=>3, :line=>7, :lineText=>"<h1>{hola}, {nam}</h1>", :namespace=>"file", :suggestion=>""}, :notes=>[], :pluginName=>"esbuild-svelte", :text=>"'nam' is not defined"}
@@ -1,53 +1,65 @@
1
+ require "erb"
2
+ require "svelte/helpers"
3
+
1
4
  module Svelte
5
+ DISCARD_PROPS = %w[lookup_context view_renderer current_template output_buffer view_flow rendered_format marked_for_same_origin_verification virtual_path]
6
+ ISLAND_ATTRS = {
7
+ "on:visible": "",
8
+ "on:idle": ""
9
+ }
10
+
2
11
  class Handler
3
- include ActionView::Helpers::JavaScriptHelper
4
12
  def call(template, source)
5
- file = Tempfile.new
6
- src = Tempfile.new
7
- src.write source.b
8
- src.rewind
9
-
10
- file.write <<-JS
11
- import { compile } from 'svelte/compiler';
13
+ digest = Digest::SHA256.base64digest(source)
12
14
 
13
- const src = await Bun.file("#{src.path}").text()
14
- const result = compile(src, {generate: "dom", name: "Component", css: "injected", sveltePath: "https://esm.sh/svelte"})
15
+ <<-RUBY
16
+ template = ObjectSpace._id2ref(#{template.object_id})
17
+
18
+ Svelte.props.merge!(
19
+ instance_values.reject { |k,v|
20
+ k.start_with?("_") || Svelte::DISCARD_PROPS.include?(k)
21
+ }
22
+ ) { |key,old,new| old }
23
+
24
+ Svelte.props.deep_symbolize_keys!
25
+
26
+ if local_assigns.dig(:svelte) == nil
27
+ local_assigns[:svelte] = {}
28
+ end
29
+
30
+ assembler = nil
31
+
32
+ assembler = Tempfile.new(['assembler', ".mjs"], '#{Svelte.gem_dir}/tmp')
33
+
34
+ assembler.write(ERB.new(File.read('#{Svelte.gem_dir}/lib/svelte/templates/assembler.js.erb')).result_with_hash({
35
+ path: '#{Rails.root.join template.identifier}',
36
+ locals: local_assigns,
37
+ compiled_client: j(Rails.cache.read('svelte/template/client' + '#{digest}')),
38
+ compiled_server: j(Rails.cache.read('svelte/template/server' + '#{digest}')),
39
+ ssr: Svelte.precedence(local_assigns.dig(:svelte, :ssr), template.variant.nil? ? nil : (template.variant == "server"), Svelte.ssr)
40
+ }))
41
+
42
+ assembler.rewind
15
43
 
16
- process.stdout.write(JSON.stringify(result))
17
- JS
18
-
19
- file.rewind
20
-
21
- compiled = JSON.parse(%x(bun run #{file.path})).deep_symbolize_keys
22
-
23
- return <<-RUBY
24
- Svelte::Handler.generate_island(#{(compiled.dig(:js, :code) || "").dump}, local_assigns.to_json.presence)
44
+ result = JSON.parse(`NODE_NO_WARNINGS=1 node --experimental-vm-modules \#{assembler.path}`).deep_symbolize_keys
45
+
46
+ if result[:error]
47
+ e = Svelte::CompilerError.new(result.dig(:error, :text), result.dig(:error, :location))
48
+ raise Svelte::TemplateError.new(template, e)
49
+ end
50
+
51
+ assembler&.close
52
+ assembler&.unlink
53
+
54
+ Rails.cache.write("svelte/template/client/" + '#{digest}', result[:compiled][:client], expires_in: 14.days)
55
+ Rails.cache.write("svelte/template/server/" + '#{digest}', result[:compiled][:server], expires_in: 14.days)
56
+
57
+ ERB.new(File.read("#{Svelte.gem_dir}/lib/svelte/templates/island.html.erb")).result_with_hash({ result:, locals: local_assigns, digest: "#{digest}"})
25
58
  RUBY
26
59
  end
27
60
 
28
61
  def self.call(template, source)
29
62
  new.call(template, source)
30
63
  end
31
-
32
- def self.generate_island(js, props)
33
- return <<-HTML
34
- <is-land on:visible on:idle>
35
- <template data-island>
36
- <div id="svelte-app-client">
37
- </div>
38
-
39
- <script type="module">
40
- #{js}
41
-
42
- new Component({
43
- target: document.getElementById("svelte-app-client"),
44
- props: JSON.parse(#{props.to_json.presence || "{}"})
45
- });
46
- </script>
47
-
48
- </template>
49
- </is-land>
50
- HTML
51
- end
52
64
  end
53
- end
65
+ end
@@ -1,5 +1,5 @@
1
1
  module Svelte
2
- module Helpers
2
+ module Helpers
3
3
  include ActionView::Helpers::JavaScriptHelper
4
4
  def svelte_tags
5
5
  js = <<-JS
@@ -9,8 +9,18 @@ module Svelte
9
9
 
10
10
  window.props = readable(JSON.parse("#{j(Svelte.props.to_json)}"))
11
11
  JS
12
-
12
+
13
13
  tag.script(js.html_safe, type: "module")
14
14
  end
15
+
16
+ delegate :destructure_attrs, to: :class
17
+
18
+ def self.destructure_attrs(hash)
19
+ string = ""
20
+ hash.each do |k, v|
21
+ string += "#{k}='#{j v}' "
22
+ end
23
+ string
24
+ end
15
25
  end
16
- end
26
+ end
@@ -0,0 +1,2 @@
1
+ > [!CAUTION]
2
+ > This folder is intended to be built from TypeScript files with `npm run build`
@@ -0,0 +1,122 @@
1
+ /*
2
+ IF YOU ARE READING THIS, YOU ARE VIEWING THE BUILT JS FROM THE SOURCE TYPESCRIPT AT ../ts/ .
3
+
4
+ DO NOT MODIFY.
5
+ */
6
+
7
+ import { readable } from "svelte/store";
8
+ import { importFromStringSync } from "module-from-string";
9
+ import * as esbuild from "esbuild";
10
+ import sveltePlugin from "esbuild-svelte";
11
+ import { sveltePreprocess } from "svelte-preprocess";
12
+ import * as recast from "recast";
13
+ class Builder {
14
+ path;
15
+ props;
16
+ locals;
17
+ compiled;
18
+ ssr;
19
+ workingDir;
20
+ preprocess;
21
+ constructor(path, props, locals, client, server, ssr, workingDir, preprocess) {
22
+ this.path = path;
23
+ this.props = readable(props);
24
+ this.locals = locals;
25
+ this.compiled = {
26
+ client,
27
+ server
28
+ };
29
+ this.ssr = ssr;
30
+ this.workingDir = workingDir;
31
+ this.preprocess = preprocess;
32
+ }
33
+ async bundle(generate, sveltePath = "svelte") {
34
+ const bundle = await esbuild.build({
35
+ entryPoints: [this.path],
36
+ mainFields: ["svelte", "browser", "module", "main"],
37
+ conditions: ["svelte", "browser"],
38
+ absWorkingDir: this.workingDir,
39
+ write: false,
40
+ outfile: "component.js",
41
+ bundle: true,
42
+ format: "esm",
43
+ metafile: true,
44
+ plugins: [
45
+ // @ts-expect-error
46
+ sveltePlugin({
47
+ compilerOptions: {
48
+ generate,
49
+ css: "injected",
50
+ hydratable: true,
51
+ sveltePath
52
+ },
53
+ preprocess: sveltePreprocess(this.preprocess),
54
+ filterWarnings: (warning) => {
55
+ if (warning.code === "missing-declaration" && warning.message.includes("'props'")) {
56
+ return false;
57
+ }
58
+ return true;
59
+ }
60
+ })
61
+ ]
62
+ });
63
+ const throwables = [].concat(bundle.errors, bundle.warnings);
64
+ if (throwables.length > 0) {
65
+ throw throwables[0];
66
+ }
67
+ return bundle.outputFiles[0].text;
68
+ }
69
+ async client() {
70
+ return this.compiled?.client || this.standardizeClient(await this.bundle("dom", "https://esm.sh/svelte"));
71
+ }
72
+ standardizeClient(code) {
73
+ const ast = recast.parse(code);
74
+ let name;
75
+ recast.visit(ast, {
76
+ visitExportNamedDeclaration: (path) => {
77
+ const stagingName = path.node?.specifiers?.[0].local?.name;
78
+ name = typeof stagingName !== "string" ? "" : stagingName;
79
+ path.prune();
80
+ return false;
81
+ }
82
+ });
83
+ recast.visit(ast, {
84
+ visitIdentifier: (path) => {
85
+ if (path.node.name === name) {
86
+ path.node.name = "App";
87
+ }
88
+ return false;
89
+ }
90
+ });
91
+ return recast.print(ast).code;
92
+ }
93
+ async server() {
94
+ const output = this.compiled?.server || await this.bundle("ssr");
95
+ const Component = importFromStringSync(output, {
96
+ globals: { props: this.props }
97
+ }).default;
98
+ const { html, css } = await Component.render(this.locals);
99
+ return { output, html, css: css.code };
100
+ }
101
+ async build() {
102
+ try {
103
+ const serv = this.ssr ? await this.server() : null;
104
+ const cli = await this.client();
105
+ const comp = {
106
+ client: cli,
107
+ server: serv?.output
108
+ };
109
+ return {
110
+ client: cli,
111
+ server: serv,
112
+ compiled: comp
113
+ };
114
+ } catch (e) {
115
+ return { error: e };
116
+ }
117
+ }
118
+ }
119
+ var builder_default = Builder;
120
+ export {
121
+ builder_default as default
122
+ };
@@ -3,11 +3,15 @@ require "svelte/helpers"
3
3
 
4
4
  module Svelte
5
5
  class Railtie < ::Rails::Railtie
6
- initializer "svelte.register_actionview" do
6
+ initializer "svelte" do |app|
7
7
  ActiveSupport.on_load :action_view do
8
8
  ActionView::Template.register_template_handler :svelte, Svelte::Handler
9
9
  ActionView::Base.send :include, Svelte::Helpers
10
10
  end
11
+
12
+ ActionController::Base.class_eval <<-RUBY, __FILE__, __LINE__ + 1
13
+ include Svelte::Variabilization
14
+ RUBY
11
15
  end
12
16
  end
13
17
  end
@@ -0,0 +1,16 @@
1
+ import Builder from "<%= "#{Svelte.gem_dir}/lib/svelte/js/builder.js" %>";
2
+
3
+ const bob = new Builder(
4
+ '<%= path %>',
5
+ JSON.parse('<%= Svelte.props.to_json || "{}" %>'),
6
+ JSON.parse('<%= locals.to_json || "{}" %>'),
7
+ '<%= compiled_client || "" %>',
8
+ '<%= compiled_server || "" %>',
9
+ <%= ssr %>,
10
+ '<%= Rails.root %>',
11
+ JSON.parse('<%= Svelte.preprocess.to_json || "{}" %>')
12
+ )
13
+
14
+ const built = await bob.build()
15
+
16
+ process.stdout.write(JSON.stringify(built))
@@ -0,0 +1,20 @@
1
+ <is-land <%= Svelte::Helpers.destructure_attrs((locals.dig(:svelte, :island) rescue nil) || Svelte::ISLAND_ATTRS) %>>
2
+ <div id="hydratee-<%= digest %>">
3
+ <%= result.dig(:server, :html) %>
4
+ </div>
5
+
6
+ <style>
7
+ <%= result.dig(:server, :css) %>
8
+ </style>
9
+ <template data-island>
10
+ <script type="module">
11
+ <%= result.dig(:client) %>
12
+
13
+ new App({
14
+ target: document.getElementById("hydratee-<%= digest %>"),
15
+ props: JSON.parse('<%= locals.to_json %>'),
16
+ hydrate: true
17
+ });
18
+ </script>
19
+ </template>
20
+ </is-land>
@@ -0,0 +1,11 @@
1
+ module Svelte
2
+ module Variabilization
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ before_action do
7
+ request.variant = Array(request.variant).concat([:client, :server])
8
+ end
9
+ end
10
+ end
11
+ end
@@ -1,3 +1,3 @@
1
1
  module Svelte
2
- VERSION = "0.2.0"
2
+ VERSION = "0.4.1"
3
3
  end
data/lib/svelte.rb CHANGED
@@ -1,11 +1,40 @@
1
+ require "active_support/core_ext"
2
+ require "svelte/variabilization"
1
3
  require "svelte/railtie"
2
- require "svelte/handler"
4
+ require "svelte/errors"
3
5
  require "svelte/helpers"
6
+ require "svelte/handler"
4
7
  require "svelte/version"
5
8
  require "active_support/isolated_execution_state"
6
9
 
7
10
  module Svelte
11
+ include ActionView::Helpers::JavaScriptHelper
12
+
13
+ def self.configure
14
+ yield(self)
15
+ end
16
+
17
+ # @dynamic self.ssr, self.aliases, self.preprocess
18
+ # @dynamic self.ssr=, self.aliases=, self.preprocess=
19
+ mattr_accessor :ssr, default: true
20
+ mattr_accessor :aliases, default: {}
21
+ mattr_accessor :preprocess, default: {}
22
+
8
23
  def self.props
9
- ActiveSupport::IsolatedExecutionState[:svelte_props] ||= {}
24
+ ActiveSupport::IsolatedExecutionState[:svelte_props] ||= {} # steep:ignore UnknownConstant
25
+ end
26
+
27
+ def self.gem_dir
28
+ Gem::Specification.find_by_name("actionview-svelte-handler").gem_dir
29
+ end
30
+
31
+ def self.precedence(*args)
32
+ args.each do |v|
33
+ if !v.nil?
34
+ return v
35
+ end
36
+ end
37
+
38
+ nil
10
39
  end
11
40
  end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :svelte do
3
+ # # Task goes here
4
+ # end