Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ An experimental tool to generate JavaScript code from Crystal code.
* [Code Snippets](#javascript-code)
* [`_call`](#_call)
* [`async` / `await`](#async--await)
* [Strict Mode (opt-in)](#strict-mode-opt-in)
* [Browser API Wrappers](#browser-api-wrappers)
* [Functions](#javascript-functions)
* [Classes](#javascript-classes)
* [Files](#javascript-files)
Expand Down Expand Up @@ -88,6 +90,62 @@ end
puts MyAsyncSnippet.to_js
```

#### Strict Mode (opt-in)

You can enable strict mode per generated unit:

- `JS::Code`: `def_to_js strict: true do ... end`
- `JS::File`: `def_to_js strict: true do ... end`
- `JS::Module`: `def_to_js strict: true do ... end`

In strict mode:

- Referencing/calling undeclared JS identifiers raises a compile-time error.
- `_literal_js(...)` is rejected at compile-time.

```crystal
require "js"

class MyStrictCode < JS::Code
def_to_js strict: true do
console.log("ready")
end
end
```

### Browser API Wrappers

In strict mode, method calls without an explicit receiver are resolved against a default browser context object.
For now, this context exposes `console` with:

- `log`
- `info`
- `warn`
- `error`

Use regular-looking calls (instead of wrapper constants or `_literal_js`):

```crystal
class MyConsoleCode < JS::Code
def_to_js strict: true do
console.log("Hello", 7, true)
end
end
```

#### Adding wrappers iteratively

Use this pattern for additional browser APIs:

1. Add/update `JS::Context::Browser` with the new receiverless entrypoint (like `console`).
2. Add a wrapper under `src/js/context/<api>.cr` in `JS::Context`.
3. Inherit browser wrappers from `JS::Context::ContextObject`, which stores the current JS call chain and provides call-chain initialization.
4. Return a typed browser context object from each wrapper method (for now, `console.log/info/warn/error` return `JS::Context::Undefined`).
5. Add specs that:
- verify JS output from wrapper calls;
- verify typed return wrappers and their `to_js_ref` output;
- verify strict mode acceptance when wrappers are used.

### JavaScript Functions

If you were wondering how to define a function within `JS::Code.def_to_js` - that's not possible. Well, technically it is via a `_literal_js` call but that's dirty and there is a better way:
Expand Down
75 changes: 75 additions & 0 deletions spec/js/code/strict_mode_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
require "../../spec_helper"

module JS::Code::StrictModeSpec
class StrictCode < JS::Code
js_alias "doc", "document"

def_to_js strict: true do
doc.querySelector("body")
console.info("ready")
end
end

class LooseCode < JS::Code
def_to_js do
undeclaredThing.callMe._call
end
end

describe "strict mode" do
it "allows declared externs and typed wrappers" do
expected = <<-JS.squish
document.querySelector("body");
console.info("ready");
JS

StrictCode.to_js.should eq(expected)
end

it "keeps loose mode backwards-compatible" do
expected = <<-JS.squish
undeclaredThing.callMe();
JS

LooseCode.to_js.should eq(expected)
end

it "fails on undeclared identifiers in strict mode" do
source = <<-CR
require "./src/js"

class StrictFailureCode < JS::Code
def_to_js strict: true do
missing_api._call
end
end

puts StrictFailureCode.to_js
CR

exit_code, _stdout, stderr = crystal_eval(source)
exit_code.should_not eq(0)
stderr.should contain("undefined method")
stderr.should contain("missing_api")
end

it "fails on _literal_js in strict mode" do
source = <<-CR
require "./src/js"

class StrictLiteralCode < JS::Code
def_to_js strict: true do
_literal_js("alert('nope')")
end
end

puts StrictLiteralCode.to_js
CR

exit_code, _stdout, stderr = crystal_eval(source)
exit_code.should_not eq(0)
stderr.should contain("undefined method")
stderr.should contain("_literal_js")
end
end
end
34 changes: 34 additions & 0 deletions spec/js/context/console_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
require "../../spec_helper"

module JS::Context::APISpec
class StrictConsoleCode < JS::Code
def_to_js strict: true do
console.log("Hello", 7, true, nil)
console.info("Info")
console.warn("Warn")
console.error("Error")
end
end

describe "strict browser context console calls" do
it "transpiles literal console calls in strict mode" do
expected = <<-JS.squish
console.log("Hello", 7, true, undefined);
console.info("Info");
console.warn("Warn");
console.error("Error");
JS

StrictConsoleCode.to_js.should eq(expected)
end
end

describe "typed console return value" do
it "returns an Undefined context wrapper exposing to_js_ref" do
result = JS::Context.default.console.log("Hello")

result.should be_a(JS::Context::Undefined)
result.to_js_ref.should eq("console.log(\"Hello\")")
end
end
end
21 changes: 21 additions & 0 deletions spec/js/file/strict_mode_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
require "../../spec_helper"

module JS::File::StrictModeSpec
class StrictFile < JS::File
js_alias "doc", "document"

def_to_js strict: true do
doc.body.classList.add("active")
end
end

describe "StrictFile.to_js" do
it "supports strict mode opt-in" do
expected = <<-JS.squish
document.body.classList.add("active");
JS

StrictFile.to_js.should eq(expected)
end
end
end
26 changes: 26 additions & 0 deletions spec/js/module/strict_mode_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
require "../../spec_helper"

module JS::Module::StrictModeSpec
class StrictModule < JS::Module
js_import Controller, from: "/assets/stimulus.js"
js_alias "doc", "document"

def_to_js strict: true do
title = doc.querySelector("title")
console.log(title)
end
end

describe "StrictModule.to_js" do
it "supports strict mode opt-in" do
expected = <<-JS.squish
import { Controller } from "/assets/stimulus.js";
var title;
title = document.querySelector("title");
console.log(title);
JS

StrictModule.to_js.should eq(expected)
end
end
end
15 changes: 15 additions & 0 deletions spec/spec_helper.cr
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,18 @@ class String
split(/\n\s*/).join
end
end

def crystal_eval(source : String)
stdout = IO::Memory.new
stderr = IO::Memory.new
status = Process.run(
"crystal",
["eval", source],
chdir: File.expand_path("..", __DIR__),
env: {"CRYSTAL_CACHE_DIR" => "/tmp/.crystal-cache-js"},
output: stdout,
error: stderr
)

{status.exit_code, stdout.to_s, stderr.to_s}
end
4 changes: 4 additions & 0 deletions src/ext/bool.cr
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,8 @@ struct Bool
def self.to_js_ref
"Boolean"
end

def to_js_ref
self ? "true" : "false"
end
end
11 changes: 11 additions & 0 deletions src/ext/float.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
struct Float32
def to_js_ref
self
end
end

struct Float64
def to_js_ref
self
end
end
5 changes: 5 additions & 0 deletions src/ext/nil.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
struct Nil
def to_js_ref
"undefined"
end
end
1 change: 1 addition & 0 deletions src/js.cr
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
require "./js/module"
require "./js/context/*"
require "./ext/*"
Loading