klenod-plugin-javascript 0.0.1

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,521 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "klenod/build/asset"
4
+ require "klenod/build/dependency"
5
+ require "klenod/build/errors"
6
+ require "klenod/build/hashing"
7
+ require "klenod/build/load_result"
8
+ require "klenod/build/module_id"
9
+ require "klenod/build/plugin"
10
+ require "klenod/build/source_map"
11
+ require "klenod/build/transform_result"
12
+ require "klenod/runtime"
13
+
14
+ require_relative "../../plugin/javascript/parser"
15
+
16
+ module Klenod
17
+ module Build
18
+ module Plugins
19
+ module JavaScriptPlugin
20
+ def self.new(...)
21
+ Plugin.new(...)
22
+ end
23
+
24
+ class Plugin < Klenod::Build::Plugin
25
+ CONTENT_TYPE = "application/javascript"
26
+ EXTENSIONS = [".js", ".jsx", ".ts", ".tsx"].freeze
27
+ CUSTOM_ELEMENT_EXTENSIONS = [".jsx", ".tsx"].freeze
28
+ JSX_RUNTIME_SPECIFIER = "virtual:klenod/jsx-runtime"
29
+ JSX_RUNTIME_MODULE_ID = ModuleId.new("virtual:klenod/jsx-runtime.js", nil)
30
+ LOCAL_SPECIFIER_PATTERN = %r{\A(?:\.{1,2}/|/|app:/)}
31
+ EXTERNAL_SPECIFIER_PATTERN = %r{\A(?:[A-Za-z][A-Za-z0-9+.-]*:)?//}
32
+ VALID_SOURCE_MAP_MODES = [false, true, :development].freeze
33
+ IDENTIFIER_PATTERN = '[$_\p{Alpha}][$\u200c\u200d\p{Alnum}_]*'
34
+ DEFAULT_EXPORT_CLASS_PATTERN = /\bexport\s+default\s+class\s+(#{IDENTIFIER_PATTERN})\b/
35
+ DEFAULT_EXPORT_IDENTIFIER_PATTERN = /\bexport\s+default\s+(#{IDENTIFIER_PATTERN})\s*;/
36
+ DEFAULT_IMPORT_PREFIX_PATTERN = /\Aimport\s+#{IDENTIFIER_PATTERN}\s+from\z/
37
+
38
+ def initialize(source_maps: :development, minify: false)
39
+ unless VALID_SOURCE_MAP_MODES.include?(source_maps)
40
+ raise ArgumentError, "source_maps must be false, true, or :development"
41
+ end
42
+
43
+ @source_maps = source_maps
44
+ @minify = minify
45
+ end
46
+
47
+ def resolve(dependency, context)
48
+ return ResolvedDependency.new(dependency, JSX_RUNTIME_MODULE_ID, {virtual: true}) if dependency.specifier == JSX_RUNTIME_SPECIFIER
49
+ return nil unless dependency.kind.to_s.start_with?("javascript_")
50
+ return nil unless extensionless_javascript_specifier?(dependency.specifier)
51
+
52
+ extensionless_resolve_extensions(dependency).each do |extension|
53
+ return context.resolve_dependency(dependency.with(specifier: "#{dependency.specifier}#{extension}"))
54
+ rescue ResolveError
55
+ next
56
+ end
57
+
58
+ nil
59
+ end
60
+
61
+ def load(module_id, _context)
62
+ return nil unless module_id.scheme == :virtual && module_id == JSX_RUNTIME_MODULE_ID
63
+
64
+ LoadResult.new(jsx_runtime_source, nil, nil)
65
+ end
66
+
67
+ def transform(module_id, code, context)
68
+ return super unless EXTENSIONS.include?(module_id.extname)
69
+
70
+ custom_element = custom_element_module?(module_id)
71
+ jsx_runtime_namespace = custom_element ? jsx_runtime_namespace(module_id) : nil
72
+ transform = Parser.transform(
73
+ code,
74
+ filename: module_id.to_s,
75
+ source_kind: source_kind(module_id),
76
+ minify: minify_enabled?(context),
77
+ jsx_runtime_namespace: jsx_runtime_namespace
78
+ )
79
+ javascript_source, imports =
80
+ if custom_element
81
+ inject_jsx_runtime(transform.code, transform.imports, module_id, jsx_runtime_namespace)
82
+ else
83
+ [transform.code, transform.imports]
84
+ end
85
+ dependencies = build_dependencies(module_id, imports)
86
+
87
+ Klenod::Build::TransformResult.new(
88
+ module_source(nil),
89
+ dependencies,
90
+ nil,
91
+ [],
92
+ [],
93
+ {
94
+ javascript_source: javascript_source,
95
+ javascript_original_source: code,
96
+ javascript_imports: imports,
97
+ javascript_custom_element: custom_element,
98
+ javascript_custom_element_tag: custom_element ? custom_element_tag(module_id) : nil
99
+ }
100
+ )
101
+ end
102
+
103
+ def finalize(module_id, result, resolved_dependencies, dependency_records, context)
104
+ source = result.metadata[:javascript_source]
105
+ original_source = result.metadata[:javascript_original_source]
106
+ imports = result.metadata[:javascript_imports]
107
+ return result unless source && imports
108
+
109
+ edit = rewrite_imports(module_id, source, original_source, imports, resolved_dependencies, dependency_records)
110
+ source_map_asset = nil
111
+ code = edit.code
112
+ custom_element_descriptor = nil
113
+
114
+ if result.metadata[:javascript_custom_element]
115
+ tag = result.metadata.fetch(:javascript_custom_element_tag)
116
+ code = register_custom_element(module_id, code, tag)
117
+ custom_element_descriptor = custom_element_descriptor(tag, nil)
118
+ end
119
+
120
+ if source_maps_enabled?(context)
121
+ source_map_json = edit.source_map.to_json
122
+ source_map_hash = Hashing.short(source_map_json)
123
+ source_map_output_path = "/assets/#{asset_name(module_id)}.#{source_map_hash}.js.map"
124
+ source_map_asset =
125
+ Asset.new(
126
+ module_id.path,
127
+ source_map_hash,
128
+ source_map_output_path,
129
+ nil,
130
+ source_map_json,
131
+ "application/json",
132
+ {type: :javascript_source_map}
133
+ )
134
+ code = "#{code.chomp}\n//# sourceMappingURL=#{File.basename(source_map_output_path)}\n"
135
+ end
136
+
137
+ hash = Hashing.short(code)
138
+ output_path = "/assets/#{asset_name(module_id)}.#{hash}.js"
139
+ asset_javascript_assets = asset_javascript_assets(resolved_dependencies, dependency_records)
140
+ metadata = {type: :javascript}
141
+ css_preload_assets = css_preload_assets(resolved_dependencies, dependency_records)
142
+ metadata[:preload_assets] = css_preload_assets unless css_preload_assets.empty?
143
+ asset =
144
+ Asset.new(
145
+ module_id.path,
146
+ hash,
147
+ output_path,
148
+ nil,
149
+ code,
150
+ CONTENT_TYPE,
151
+ metadata
152
+ )
153
+
154
+ result.with(
155
+ code: module_source(output_path, custom_element_descriptor: custom_element_descriptor&.merge(asset_path: output_path)),
156
+ assets: [asset, source_map_asset, *asset_javascript_assets].compact,
157
+ metadata: result.metadata.merge(
158
+ javascript_asset_path: output_path,
159
+ javascript_custom_element_descriptor: custom_element_descriptor&.merge(asset_path: output_path)
160
+ )
161
+ )
162
+ end
163
+
164
+ def import_value(_resolved_dependency, record, context)
165
+ return nil unless EXTENSIONS.include?(record.id.extname)
166
+
167
+ context.mods.fetch(record.id).const_get(:Exports)::Default
168
+ end
169
+
170
+ def runtime_import_value(_resolved_dependency, record, _context)
171
+ return nil unless EXTENSIONS.include?(record.id.extname)
172
+
173
+ return record.metadata.fetch(:javascript_custom_element_descriptor) if record.metadata[:javascript_custom_element]
174
+
175
+ record.metadata.fetch(:javascript_asset_path)
176
+ end
177
+
178
+ private
179
+
180
+ def custom_element_module?(module_id)
181
+ CUSTOM_ELEMENT_EXTENSIONS.include?(module_id.extname)
182
+ end
183
+
184
+ def inject_jsx_runtime(code, imports, module_id, namespace)
185
+ prefix = %(import * as #{namespace} from "#{JSX_RUNTIME_SPECIFIER}";\n)
186
+ [
187
+ "#{prefix}#{code}",
188
+ [
189
+ jsx_runtime_import_record(prefix, module_id),
190
+ *imports.map { shifted_import_record(it, prefix.length) }
191
+ ]
192
+ ]
193
+ end
194
+
195
+ def jsx_runtime_import_record(prefix, module_id)
196
+ start_offset = prefix.index(JSX_RUNTIME_SPECIFIER) || raise(KeyError, "Missing JSX runtime specifier in injected import")
197
+ ImportRecord.new(
198
+ JSX_RUNTIME_SPECIFIER,
199
+ :javascript_import,
200
+ start_offset,
201
+ start_offset + JSX_RUNTIME_SPECIFIER.length,
202
+ {},
203
+ "#{module_id}:1:#{start_offset + 1}"
204
+ )
205
+ end
206
+
207
+ def shifted_import_record(import, offset)
208
+ ImportRecord.new(
209
+ import.specifier,
210
+ import.kind,
211
+ import.start_offset + offset,
212
+ import.end_offset + offset,
213
+ import.attributes,
214
+ import.loc
215
+ )
216
+ end
217
+
218
+ def build_dependencies(module_id, imports)
219
+ imports.filter_map.with_index do |import, index|
220
+ next if external_specifier?(import.specifier)
221
+ raise DynamicImportError, unsupported_specifier_message(import) unless local_specifier?(import.specifier) || import.specifier == JSX_RUNTIME_SPECIFIER
222
+
223
+ Dependency
224
+ .create(
225
+ specifier: import.specifier,
226
+ importer_id: module_id,
227
+ kind: import.kind,
228
+ loc: source_location(import.loc, module_id),
229
+ metadata: {
230
+ start_offset: import.start_offset,
231
+ end_offset: import.end_offset,
232
+ attribute_insert_offset: import.end_offset + 1,
233
+ attributes: import.attributes
234
+ }
235
+ )
236
+ .with(id: "#{module_id}:dependency:#{index}")
237
+ end
238
+ end
239
+
240
+ def source_location(value, module_id)
241
+ match = value.to_s.match(/\A(?<path>.*):(?<line>\d+):(?<column>\d+)\z/)
242
+ return SourceLocation.new(module_id.to_s, nil, nil) unless match
243
+
244
+ SourceLocation.new(match[:path], match[:line].to_i, match[:column].to_i)
245
+ end
246
+
247
+ def rewrite_imports(module_id, source, original_source, imports, resolved_dependencies, dependency_records)
248
+ resolved_by_range =
249
+ resolved_dependencies.to_h do |resolved_dependency|
250
+ metadata = resolved_dependency.dependency.metadata
251
+ range = [metadata.fetch(:start_offset), metadata.fetch(:end_offset)]
252
+ record = dependency_records.fetch(resolved_dependency.dependency.id)
253
+ [range, [resolved_dependency.dependency, record]]
254
+ end
255
+
256
+ edits =
257
+ imports.flat_map do |import|
258
+ dependency, record = resolved_by_range[[import.start_offset, import.end_offset]]
259
+ next [] unless dependency
260
+
261
+ import_edits(module_id, source, dependency, record)
262
+ end
263
+
264
+ SourceMap::Editor.new(source, identity_source_map(module_id, source, original_source)).apply(edits)
265
+ end
266
+
267
+ def import_edits(module_id, source, dependency, record)
268
+ if (stylesheet_asset = css_javascript_stylesheet_asset(record))
269
+ validate_css_import!(module_id, source, dependency)
270
+ edits = [SourceMap::Edit.replace(dependency.metadata.fetch(:start_offset), dependency.metadata.fetch(:end_offset), stylesheet_asset.output_path)]
271
+ if dependency.metadata.fetch(:attributes)[:type].nil?
272
+ edits << SourceMap::Edit.replace(dependency.metadata.fetch(:attribute_insert_offset), dependency.metadata.fetch(:attribute_insert_offset), %( with { type: "css" }))
273
+ end
274
+ return edits
275
+ end
276
+
277
+ if (asset_path = asset_javascript_asset_path(record))
278
+ validate_asset_import!(module_id, source, dependency)
279
+ return [SourceMap::Edit.replace(dependency.metadata.fetch(:start_offset), dependency.metadata.fetch(:end_offset), asset_path)]
280
+ end
281
+
282
+ [SourceMap::Edit.replace(dependency.metadata.fetch(:start_offset), dependency.metadata.fetch(:end_offset), record.metadata.fetch(:javascript_asset_path))]
283
+ end
284
+
285
+ def asset_javascript_asset_path(record)
286
+ record.metadata[:image_javascript_asset_path] || record.metadata[:svg_javascript_asset_path]
287
+ end
288
+
289
+ def asset_javascript_assets(resolved_dependencies, dependency_records)
290
+ resolved_dependencies.filter_map do |resolved_dependency|
291
+ record = dependency_records.fetch(resolved_dependency.dependency.id)
292
+ asset = record.assets.find { asset_javascript_metadata?(it) }
293
+ next unless asset
294
+
295
+ javascript_asset(asset)
296
+ end
297
+ end
298
+
299
+ def asset_javascript_metadata?(asset)
300
+ asset.metadata[:type] == :image_javascript_metadata || asset.metadata[:type] == :svg_javascript_metadata
301
+ end
302
+
303
+ def javascript_asset(asset)
304
+ Asset.new(
305
+ asset.logical_name,
306
+ asset.content_hash,
307
+ asset.output_path,
308
+ asset.source_path,
309
+ asset.bytes,
310
+ asset.content_type,
311
+ asset.metadata.merge(type: :javascript)
312
+ )
313
+ end
314
+
315
+ def css_javascript_stylesheet_asset(record)
316
+ return nil unless record.id.extname == ".css"
317
+
318
+ record.assets.find { it.metadata[:type] == :css_javascript_stylesheet } || record.assets.find { it.metadata[:type] == :css }
319
+ end
320
+
321
+ def css_preload_assets(resolved_dependencies, dependency_records)
322
+ resolved_dependencies.filter_map do |resolved_dependency|
323
+ record = dependency_records.fetch(resolved_dependency.dependency.id)
324
+ asset = css_javascript_stylesheet_asset(record)
325
+ {path: asset.output_path, as: "style"} if asset
326
+ end.uniq
327
+ end
328
+
329
+ def validate_asset_import!(module_id, source, dependency)
330
+ return if dependency.kind == :javascript_import && default_import?(source, dependency)
331
+
332
+ raise DynamicImportError, "Unsupported asset import #{dependency.specifier.inspect} in #{module_id}. Use a default import, e.g. `import asset from \"#{dependency.specifier}\"`."
333
+ end
334
+
335
+ def validate_css_import!(module_id, source, dependency)
336
+ validate_asset_import!(module_id, source, dependency)
337
+ type = dependency.metadata.fetch(:attributes)[:type]
338
+ return if type.nil? || type == "css"
339
+
340
+ raise DynamicImportError, "Unsupported CSS import attribute type #{type.inspect} for #{dependency.specifier.inspect} in #{module_id}. CSS imports must use `with { type: \"css\" }`."
341
+ end
342
+
343
+ def default_import?(source, dependency)
344
+ start_offset = dependency.metadata.fetch(:start_offset)
345
+ statement_start = source.rindex(/\bimport\b/, start_offset) || 0
346
+ prefix = source[statement_start...start_offset].sub(/["']\z/, "").strip
347
+ prefix.match?(DEFAULT_IMPORT_PREFIX_PATTERN)
348
+ end
349
+
350
+ def register_custom_element(module_id, code, tag)
351
+ constructor = custom_element_constructor(module_id, code)
352
+ <<~JS
353
+ #{code.chomp}
354
+ Object.defineProperty(#{constructor}, "__klenodCustomElementTag", { value: #{tag.inspect} });
355
+ customElements.define(#{tag.inspect}, #{constructor});
356
+ JS
357
+ end
358
+
359
+ def custom_element_constructor(module_id, code)
360
+ if (match = code.match(DEFAULT_EXPORT_CLASS_PATTERN))
361
+ return match[1]
362
+ end
363
+
364
+ if (match = code.match(DEFAULT_EXPORT_IDENTIFIER_PATTERN))
365
+ return match[1]
366
+ end
367
+
368
+ raise Error, "\"custom element\" modules must default-export a named class or identifier: #{module_id}"
369
+ end
370
+
371
+ def custom_element_descriptor(tag, asset_path)
372
+ {
373
+ __klenod_custom_element: true,
374
+ tag: tag,
375
+ asset_path: asset_path
376
+ }
377
+ end
378
+
379
+ def identity_source_map(module_id, source, original_source)
380
+ SourceMap::Map.new(
381
+ version: 3,
382
+ source_root: nil,
383
+ sources: [module_id.path],
384
+ sources_content: [original_source || source],
385
+ names: [],
386
+ segments: identity_segments(source)
387
+ )
388
+ end
389
+
390
+ def identity_segments(source)
391
+ line_count = source.count("\n") + 1
392
+ line_count.times.map do |line|
393
+ SourceMap::Segment.new(line, 0, 0, line, 0, nil)
394
+ end
395
+ end
396
+
397
+ def source_maps_enabled?(context)
398
+ case @source_maps
399
+ when true
400
+ true
401
+ when :development
402
+ context.mode == :development
403
+ else
404
+ false
405
+ end
406
+ end
407
+
408
+ def minify_enabled?(context)
409
+ context.mode == :build || @minify
410
+ end
411
+
412
+ def unsupported_specifier_message(import)
413
+ "Unsupported JavaScript import #{import.specifier.inspect} at #{import.loc}. Only relative, app-root, and external URL imports are supported."
414
+ end
415
+
416
+ def local_specifier?(specifier)
417
+ specifier.match?(LOCAL_SPECIFIER_PATTERN)
418
+ end
419
+
420
+ def external_specifier?(specifier)
421
+ specifier.match?(EXTERNAL_SPECIFIER_PATTERN)
422
+ end
423
+
424
+ def extensionless_javascript_specifier?(specifier)
425
+ local_specifier?(specifier) && File.extname(specifier).empty?
426
+ end
427
+
428
+ def extensionless_resolve_extensions(dependency)
429
+ case dependency.importer_id&.extname
430
+ when ".tsx"
431
+ [".tsx", ".ts", ".jsx", ".js"]
432
+ when ".ts"
433
+ [".ts", ".js"]
434
+ when ".jsx"
435
+ [".jsx", ".js"]
436
+ else
437
+ [".js"]
438
+ end
439
+ end
440
+
441
+ def source_kind(module_id)
442
+ case module_id.extname
443
+ when ".ts"
444
+ :typescript
445
+ when ".jsx"
446
+ :javascript_jsx
447
+ when ".tsx"
448
+ :typescript_jsx
449
+ else
450
+ :javascript
451
+ end
452
+ end
453
+
454
+ def jsx_runtime_source
455
+ <<~JS
456
+ export function h(type, attrs, ...children) {
457
+ if (type && typeof type.__klenodCustomElementTag === "string") {
458
+ return h(type.__klenodCustomElementTag, attrs, ...children);
459
+ }
460
+ if (typeof type === "function") return type(attrs, ...children);
461
+
462
+ const el = document.createElement(type);
463
+
464
+ for (const [key, value] of Object.entries(attrs || {})) {
465
+ if (value) {
466
+ if (value === true) {
467
+ el.setAttribute(key, key);
468
+ } else {
469
+ el.setAttribute(key, value);
470
+ }
471
+ }
472
+ }
473
+
474
+ appendChildren(el, children);
475
+ return el;
476
+ }
477
+
478
+ export function Fragment(_attrs, ...children) {
479
+ const fragment = document.createDocumentFragment();
480
+ appendChildren(fragment, children);
481
+ return fragment;
482
+ }
483
+
484
+ function appendChildren(parent, children) {
485
+ children.flat().forEach((child) => {
486
+ if (child instanceof Node) {
487
+ parent.appendChild(child);
488
+ } else if (child) {
489
+ parent.appendChild(document.createTextNode(String(child)));
490
+ }
491
+ });
492
+ }
493
+ JS
494
+ end
495
+
496
+ def module_source(output_path, custom_element_descriptor: nil)
497
+ default = custom_element_descriptor || output_path
498
+
499
+ <<~RUBY
500
+ Default = #{default.inspect}
501
+ RUBY
502
+ end
503
+
504
+ def asset_name(module_id)
505
+ module_id.path.gsub(/[^A-Za-z0-9]+/, "_").sub(/\A_+/, "").sub(/_+\z/, "")
506
+ end
507
+
508
+ def custom_element_tag(module_id)
509
+ name = asset_name(module_id).downcase.gsub(/_+/, "-")
510
+ hash = Hashing.short(module_id.to_s, length: 8)
511
+ "klenod-#{name}-#{hash}"
512
+ end
513
+
514
+ def jsx_runtime_namespace(module_id)
515
+ "__klenod_jsx_#{Hashing.short(module_id.to_s, length: 8)}"
516
+ end
517
+ end
518
+ end
519
+ end
520
+ end
521
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Klenod
4
+ module Build
5
+ module Plugins
6
+ module JavaScriptPlugin
7
+ ImportRecord = Data.define(:specifier, :kind, :start_offset, :end_offset, :attributes, :loc)
8
+ TransformResult = Data.define(:code, :imports)
9
+
10
+ module Parser
11
+ module_function
12
+
13
+ def native?
14
+ !native_parser.nil?
15
+ end
16
+
17
+ def parse(source, filename:)
18
+ transform(source, filename: filename, source_kind: :javascript, minify: false).imports
19
+ end
20
+
21
+ def transform(source, filename:, source_kind:, minify: false, jsx_runtime_namespace: nil)
22
+ if NATIVE_SOURCE_KINDS.include?(source_kind)
23
+ native = native_parser || raise(SyntaxError, "TypeScript support requires the klenod-plugin-javascript native extension. Run `bundle exec rake compile` in gems/klenod-plugin-javascript.")
24
+ return native_transform_result(native.transform_native(source, filename, source_kind.to_s, {"minify" => minify, "jsx_runtime_namespace" => jsx_runtime_namespace}))
25
+ end
26
+
27
+ if (native = native_parser)
28
+ return native_transform_result(native.transform_native(source, filename, "javascript", {"minify" => minify, "jsx_runtime_namespace" => jsx_runtime_namespace}))
29
+ end
30
+
31
+ scanner = FallbackScanner.new(source, filename)
32
+ TransformResult.new(source, scanner.imports)
33
+ end
34
+
35
+ NATIVE_SOURCE_KINDS = [:typescript, :javascript_jsx, :typescript_jsx].freeze
36
+
37
+ def native_parser
38
+ @native_parser =
39
+ if defined?(@native_parser)
40
+ @native_parser
41
+ else
42
+ begin
43
+ require "klenod/plugin/javascript/native"
44
+ Klenod::Build::Plugins::JavaScriptPlugin::Native
45
+ rescue LoadError
46
+ nil
47
+ end
48
+ end
49
+ end
50
+
51
+ def native_import_record(record)
52
+ ImportRecord.new(
53
+ record.fetch("specifier"),
54
+ record.fetch("kind").to_sym,
55
+ record.fetch("start_offset"),
56
+ record.fetch("end_offset"),
57
+ record.fetch("attributes").transform_keys(&:to_sym),
58
+ record.fetch("loc")
59
+ )
60
+ end
61
+
62
+ def native_transform_result(result)
63
+ TransformResult.new(
64
+ result.fetch("code"),
65
+ result.fetch("imports").map { native_import_record(it) }
66
+ )
67
+ end
68
+
69
+ class FallbackScanner
70
+ STATIC_IMPORT_PATTERN =
71
+ /
72
+ \bimport
73
+ (?:\s+type)?
74
+ (?:
75
+ \s* (?<side_effect>["'][^"']+["'])
76
+ |
77
+ (?<clause>[\s\S]*?) \s+ from \s+ (?<from>["'][^"']+["'])
78
+ )
79
+ (?:\s+with\s+\{(?<attributes>[^}]*)\})?
80
+ /x
81
+ EXPORT_FROM_PATTERN = /\bexport\b[\s\S]*?\bfrom\s+(?<from>["'][^"']+["'])/
82
+ DYNAMIC_IMPORT_PATTERN = /\bimport\s*\(\s*(?<from>["'][^"']+["'])\s*\)/
83
+
84
+ def initialize(source, filename)
85
+ @source = source
86
+ @filename = filename
87
+ end
88
+
89
+ def imports
90
+ records = []
91
+ scan_regex(STATIC_IMPORT_PATTERN, :javascript_import, records) { |match| match[:side_effect] || match[:from] }
92
+ scan_regex(EXPORT_FROM_PATTERN, :javascript_export, records) { |match| match[:from] }
93
+ scan_regex(DYNAMIC_IMPORT_PATTERN, :javascript_dynamic_import, records) { |match| match[:from] }
94
+ records.sort_by(&:start_offset)
95
+ end
96
+
97
+ private
98
+
99
+ def scan_regex(pattern, kind, records)
100
+ @source.to_enum(:scan, pattern).each do
101
+ match = Regexp.last_match
102
+ next if inside_comment_or_string?(match.begin(0))
103
+
104
+ literal = yield(match)
105
+ start_offset = @source.index(literal, match.begin(0)) + 1
106
+ end_offset = start_offset + literal.length - 2
107
+ attributes = import_attributes(match)
108
+ records << ImportRecord.new(literal[1...-1], kind, start_offset, end_offset, attributes, loc(start_offset))
109
+ end
110
+ end
111
+
112
+ def import_attributes(match)
113
+ attributes = match.names.include?("attributes") ? match[:attributes] : nil
114
+ type = attributes&.match(/\btype\s*:\s*["'](?<type>[^"']+)["']/) { it[:type] }
115
+ type ? {type: type} : {}
116
+ end
117
+
118
+ def inside_comment_or_string?(offset)
119
+ state = :code
120
+ quote = nil
121
+ escaped = false
122
+ index = 0
123
+
124
+ while index < offset
125
+ char = @source[index]
126
+ next_char = @source[index + 1]
127
+
128
+ case state
129
+ when :code
130
+ if char == "/" && next_char == "/"
131
+ state = :line_comment
132
+ index += 1
133
+ elsif char == "/" && next_char == "*"
134
+ state = :block_comment
135
+ index += 1
136
+ elsif char == "\"" || char == "'" || char == "`"
137
+ quote = char
138
+ escaped = false
139
+ state = :string
140
+ end
141
+ when :line_comment
142
+ state = :code if char == "\n"
143
+ when :block_comment
144
+ if char == "*" && next_char == "/"
145
+ state = :code
146
+ index += 1
147
+ end
148
+ when :string
149
+ if escaped
150
+ escaped = false
151
+ elsif char == "\\"
152
+ escaped = true
153
+ elsif char == quote
154
+ state = :code
155
+ end
156
+ end
157
+
158
+ index += 1
159
+ end
160
+
161
+ state != :code
162
+ end
163
+
164
+ def loc(offset)
165
+ prefix = @source.byteslice(0, offset)
166
+ line = prefix.count("\n") + 1
167
+ column = offset - (prefix.rindex("\n") || -1)
168
+ "#{@filename}:#{line}:#{column}"
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end