actionview-svelte-handler 0.3.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,37 +1,65 @@
1
- require 'erb'
1
+ require "erb"
2
+ require "svelte/helpers"
2
3
 
3
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
+
4
11
  class Handler
5
- include ActionView::Helpers::JavaScriptHelper
6
- def call(template, source)
7
- return <<-RUBY
8
- locals = j(local_assigns.to_json).presence
9
- props = j(Svelte.props.to_json).presence
10
- source = j('#{source}')
11
- ssr = local_assigns.dig(:svelte, :ssr) != nil ? local_assigns.dig(:svelte, :ssr) : Svelte.ssr
12
-
13
- assembler = Tempfile.new(['assembler', '.mjs'], "#{Svelte.gem_dir}/tmp")
14
- assembler.write(ERB.new(File.read("#{Svelte.gem_dir}/lib/svelte/templates/assembler.js.erb")).result_with_hash({
15
- source: source,
12
+ def call(template, source)
13
+ digest = Digest::SHA256.base64digest(source)
14
+
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}',
16
36
  locals: local_assigns,
17
- ssr: ssr
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)
18
40
  }))
19
- assembler.rewind
20
41
 
42
+ assembler.rewind
43
+
21
44
  result = JSON.parse(`NODE_NO_WARNINGS=1 node --experimental-vm-modules \#{assembler.path}`).deep_symbolize_keys
22
-
23
- island = ERB.new(File.read("#{Svelte.gem_dir}/lib/svelte/templates/island.html.erb")).result_with_hash({ result: result, locals: local_assigns })
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
24
50
 
25
- assembler.close
26
- assembler.unlink
51
+ assembler&.close
52
+ assembler&.unlink
27
53
 
28
- return island
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}"})
29
58
  RUBY
30
59
  end
31
-
32
60
 
33
61
  def self.call(template, source)
34
62
  new.call(template, source)
35
63
  end
36
64
  end
37
- 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,13 +3,15 @@ require "svelte/helpers"
3
3
 
4
4
  module Svelte
5
5
  class Railtie < ::Rails::Railtie
6
- initializer "svelte" 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
- ActionView::Base.send :include, Svelte::Helpers
9
+ ActionView::Base.send :include, Svelte::Helpers
10
10
  end
11
-
12
- `npm install #{Svelte.gem_dir} --install-links --save=false`
13
- end
11
+
12
+ ActionController::Base.class_eval <<-RUBY, __FILE__, __LINE__ + 1
13
+ include Svelte::Variabilization
14
+ RUBY
15
+ end
14
16
  end
15
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.3.0"
2
+ VERSION = "0.4.1"
3
3
  end
data/lib/svelte.rb CHANGED
@@ -1,17 +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=
8
19
  mattr_accessor :ssr, default: true
9
-
20
+ mattr_accessor :aliases, default: {}
21
+ mattr_accessor :preprocess, default: {}
22
+
10
23
  def self.props
11
- ActiveSupport::IsolatedExecutionState[:svelte_props] ||= {}
24
+ ActiveSupport::IsolatedExecutionState[:svelte_props] ||= {} # steep:ignore UnknownConstant
12
25
  end
13
-
26
+
14
27
  def self.gem_dir
15
28
  Gem::Specification.find_by_name("actionview-svelte-handler").gem_dir
16
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
39
+ end
17
40
  end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :svelte do
3
+ # # Task goes here
4
+ # end
data/lib/ts/builder.ts ADDED
@@ -0,0 +1,174 @@
1
+ import { readable, Stores } from 'svelte/store'
2
+ import { importFromStringSync } from 'module-from-string'
3
+ import * as esbuild from 'esbuild'
4
+ import sveltePlugin from 'esbuild-svelte'
5
+ import { sveltePreprocess } from 'svelte-preprocess'
6
+ import type { Warning } from 'svelte/types/compiler/interfaces'
7
+ import * as recast from 'recast'
8
+
9
+ export interface BuildSuccess {
10
+ client: string
11
+ server: {
12
+ html: string
13
+ css: string
14
+ } | null
15
+ compiled: {
16
+ client: string
17
+ server: string | undefined
18
+ }
19
+ }
20
+
21
+ export interface BuildError {
22
+ error: any
23
+ }
24
+
25
+ export type BuildResult = BuildSuccess | BuildError
26
+
27
+ /**
28
+ * Builder is a class that builds, compiles and bundles Svelte components into a nice object for the template handler
29
+ */
30
+ class Builder {
31
+ path: string
32
+ props: Stores
33
+ locals: object
34
+ compiled: {
35
+ client: string | null
36
+ server: string | null
37
+ }
38
+
39
+ ssr: boolean
40
+ workingDir: string
41
+ preprocess: object
42
+
43
+ constructor (
44
+ path: string,
45
+ props: object,
46
+ locals: object,
47
+ client: string | null,
48
+ server: string | null,
49
+ ssr: boolean,
50
+ workingDir: string,
51
+ preprocess: object
52
+ ) {
53
+ this.path = path
54
+ this.props = readable(props)
55
+ this.locals = locals
56
+ this.compiled = {
57
+ client,
58
+ server
59
+ }
60
+ this.ssr = ssr
61
+ this.workingDir = workingDir
62
+ this.preprocess = preprocess
63
+ }
64
+
65
+ async bundle (generate: 'ssr' | 'dom', sveltePath = 'svelte'): Promise<string> {
66
+ const bundle = await esbuild.build({
67
+ entryPoints: [this.path],
68
+ mainFields: ['svelte', 'browser', 'module', 'main'],
69
+ conditions: ['svelte', 'browser'],
70
+ absWorkingDir: this.workingDir,
71
+ write: false,
72
+ outfile: 'component.js',
73
+ bundle: true,
74
+ format: 'esm',
75
+ metafile: true,
76
+ plugins: [
77
+ // @ts-expect-error
78
+ sveltePlugin({
79
+ compilerOptions: {
80
+ generate,
81
+ css: 'injected',
82
+ hydratable: true,
83
+ sveltePath
84
+ },
85
+ preprocess: sveltePreprocess(this.preprocess),
86
+ filterWarnings: (warning: Warning) => {
87
+ if (
88
+ warning.code === 'missing-declaration' &&
89
+ warning.message.includes("'props'")
90
+ ) {
91
+ return false
92
+ }
93
+ return true
94
+ }
95
+ })
96
+ ]
97
+ })
98
+
99
+ // @ts-expect-error
100
+ const throwables = [].concat(bundle.errors, bundle.warnings)
101
+
102
+ if (throwables.length > 0) {
103
+ throw throwables[0] // eslint-disable-line @typescript-eslint/no-throw-literal
104
+ }
105
+
106
+ return bundle.outputFiles[0].text
107
+ }
108
+
109
+ async client (): Promise<string> {
110
+ return (
111
+ this.compiled?.client || (this.standardizeClient(await this.bundle('dom', 'https://esm.sh/svelte'))) // eslint-disable-line
112
+ )
113
+ }
114
+
115
+ standardizeClient (code: string): string {
116
+ const ast = recast.parse(code)
117
+
118
+ let name: string | undefined
119
+
120
+ recast.visit(ast, {
121
+ visitExportNamedDeclaration: (path) => {
122
+ const stagingName: any = path.node?.specifiers?.[0].local?.name
123
+ name = typeof stagingName !== 'string' ? '' : stagingName
124
+ path.prune()
125
+ return false
126
+ }
127
+ })
128
+
129
+ recast.visit(ast, {
130
+ visitIdentifier: (path) => {
131
+ if (path.node.name === name) {
132
+ path.node.name = 'App'
133
+ }
134
+ return false
135
+ }
136
+ })
137
+
138
+ return recast.print(ast).code
139
+ }
140
+
141
+ async server (): Promise<{ output: string, html: string, css: string }> {
142
+ const output = this.compiled?.server || (await this.bundle('ssr')) // eslint-disable-line
143
+
144
+ const Component = importFromStringSync(output, {
145
+ globals: { props: this.props }
146
+ }).default
147
+
148
+ const { html, css } = await Component.render(this.locals)
149
+
150
+ return { output, html, css: css.code }
151
+ }
152
+
153
+ async build (): Promise<BuildResult> {
154
+ try {
155
+ const serv = this.ssr ? await this.server() : null
156
+ const cli = await this.client()
157
+
158
+ const comp = {
159
+ client: cli,
160
+ server: serv?.output
161
+ }
162
+
163
+ return {
164
+ client: cli,
165
+ server: serv,
166
+ compiled: comp
167
+ }
168
+ } catch (e) {
169
+ return { error: e }
170
+ }
171
+ }
172
+ }
173
+
174
+ export default Builder