Skip to content

Add opt-in strict mode and typed browser console wrapper#31

Merged
sbsoftware merged 10 commits intoreleases/v1.8.0from
feature/JS-45
Feb 14, 2026
Merged

Add opt-in strict mode and typed browser console wrapper#31
sbsoftware merged 10 commits intoreleases/v1.8.0from
feature/JS-45

Conversation

@sbsoftware-agent
Copy link
Collaborator

Summary

This PR introduces an opt-in strict transpilation mode for generated JS units and adds the first typed browser API wrapper surface (console).

What changed

  • Added strict mode support to:
  • JS::Code.def_to_js
  • JS::File.def_to_js
  • JS::Module.def_to_js
  • Implemented strict validation in transpilation macros:
  • compile-time failure for undeclared JS identifier references/calls
  • compile-time failure for _literal_js(...)
  • js_alias remains the explicit extern declaration escape hatch
  • Added typed browser wrapper:
  • JS::Browser::Console
  • methods: log, info, warn, error
  • receiver maps to JS console via to_js_ref
  • Wired browser wrappers into shard entrypoint (src/js.cr).
  • Added docs for:
  • strict mode opt-in syntax and behavior
  • browser wrapper usage
  • iterative wrapper expansion pattern (layout + spec expectations)

Specs

Added/updated specs for:

  • typed console wrapper output
  • strict mode opt-in behavior for JS::Code, JS::File, and JS::Module
  • strict mode compile-time failures for:
  • undeclared identifiers
  • _literal_js(...)
  • non-strict backward compatibility behavior

Validation

  • crystal tool format src spec
  • crystal tool format --check src spec
  • crystal spec

Copy link
Owner

@sbsoftware sbsoftware left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't imagine the typed browser API calls to be referenced via constants in Crystal, but via method calls looking as similar as possible. In strict mode, all method calls without an explicit receiver should be "redirected" to a default browser context object that, for now, only has a #console method, which returns a JS::Browser::Console instance. The #log, #info, etc. methods should return some kind of "method call wrapper" object with a #to_js_ref method that returns the correct call chain in valid JS as a Crystal string that is appended to the emitted JS output. This also has to work transitively for chained method calls.
In the specs, there should be no direct reference to JS::Browser::Console, but only literal console.x calls.

@sbsoftware-agent
Copy link
Collaborator Author

Implemented in commit a7ee6af (JS-45 Route strict receiverless browser calls through default context).

Addressed points from your feedback:

  • Strict-mode receiverless call redirect:

    • Added JS::Browser::Context with #console.
    • In strict mode, receiverless calls are now validated against this context and emitted via JS::Browser.default_context.<method>.to_js_ref for context entrypoints (currently console).
  • Typed wrapper flow via method calls:

    • JS::Browser::Console is now instance-based (not constant-style usage), with:
      • #log, #info, #warn, #error
    • These return JS::Browser::MethodCall.
    • JS::Browser::MethodCall exposes #to_js_ref and supports transitive chaining (including _call) so chained call refs keep building correctly.
  • Strict typed console surface:

    • Added strict compile-time validation for direct console.<method> calls so unsupported methods fail with an actionable compile-time error.
  • Specs updated per request:

    • Removed direct JS::Browser::Console references from specs.
    • Switched to literal console.x usage.
    • Added wrapper chain coverage via JS::Browser.default_context.console...to_js_ref.
    • Kept strict-mode compile-failure coverage.
  • Docs updated:

    • README now documents strict-mode browser context resolution and the wrapper-extension pattern around Context + MethodCall.

Validation:

  • crystal spec passes (45 examples, 0 failures).

Copy link
Owner

@sbsoftware sbsoftware left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's go further and get rid of the method_missing macro. Here is an example: Calling console in a def_to_js block should be redirect to the default context, which returns a JS::Browser::Console instance. Calling #log on that returns a Crystal JS context object corresponding to what the actual JS console.log method would return (I don't know what that is; we can assume nil/undefined for now), so that theoretically one could chain more correctly typed method calls behind it. Every JS context object needs to be initialized with the preceding call chain (can be the final JS string) as well as the current method name and its arguments concatenated to it. If no more calls are chained, the evaluation macro should call #to_js_ref, which returns the full call chain in valid JS code as a Crystal String that is appended to the output IO. The other case is that another method is called on the JS context object, which in turn returns a new one with the extended call chain.

This is deviating a bit from the pure-macro implementation we had so far, but it results in a cool API that is easy to extend.

@sbsoftware-agent
Copy link
Collaborator Author

Implemented in commit a81bbc6.

I removed the method_missing macro from JS::Browser::MethodCall and switched to explicit chaining APIs:

  • JS::Browser::Context#console remains the strict-mode receiverless entrypoint.
  • JS::Browser::Console#log/#info/#warn/#error now return a JS context object (JS::Browser::MethodCall).
  • JS::Browser::MethodCall now explicitly supports:
    • #to_js_ref
    • #call(name, *args) for extending call chains
    • #_call(*args) / #_call for function-call chaining
    • #property(name) for property chaining

This keeps the chain object-based model (context object carries/extends the chain and serializes via to_js_ref) while avoiding method_missing.

Spec updates:

  • Replaced dynamic .next_step("done") usage with explicit .call("next_step", "done") in spec/js/browser/console_spec.cr.
  • Full suite passes: 45 examples, 0 failures.

@sbsoftware
Copy link
Owner

That is not entirely what I meant. In fact, I don't think there should be a generic MethodCall class at all. The #log/#info/#warn/#error calls should return a JS::Browser::Undefined instance, which itself has no methods besides #to_js_ref for now. Both JS::Browser::Console and JS::Browser::Undefined should inherit from an abstract base class that defines a String instance variable holding the preceding call chain and an initializer conveniently generating that from the following input parameters: The String representing the previous call chain (can be an empty string), the name of the last method called, and a variable number of call arguments that it will serialize.
JS::Browser::Console#log(str), for example, will then return JS::Browser::Undefined.new(preceding_call_chain, "log", str).

@sbsoftware-agent
Copy link
Collaborator Author

Implemented in 8fd9c0b with the exact model you described.

Changes made:

  • Removed generic JS::Browser::MethodCall completely.
  • Added abstract base JS::Browser::ContextObject with:
    • @to_js_ref : String
    • initializer from preceding_call_chain, method_name, and varargs (serialized into the full JS call chain)
  • JS::Browser::Console < ContextObject
    • #log/#info/#warn/#error now return JS::Browser::Undefined
  • JS::Browser::Undefined < ContextObject
    • no behavior beyond #to_js_ref (inherited), as requested
  • JS::Browser::Context#console still returns Console, used by strict-mode receiverless redirection.

I also updated browser specs accordingly:

  • no MethodCall chaining assertions anymore
  • now assert console.log(...) returns JS::Browser::Undefined and produces the expected to_js_ref

Validation:

  • crystal spec passes (45 examples, 0 failures).

Copy link
Owner

@sbsoftware sbsoftware left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's swap the module names: Make JS::Context the parent one, providing .default, and JS::Context::Browser the nested one that is returned by JS::Context.default.

@sbsoftware-agent
Copy link
Collaborator Author

Implemented the namespace swap:

  • JS::Context.default now exists and returns JS::Context::Browser.
  • The browser wrapper types moved under JS::Context (Console, Undefined, ContextObject), and strict-mode dispatch now uses JS::Context.default... in src/js/code.cr.
  • Updated the typed-wrapper spec references accordingly in spec/js/browser/console_spec.cr.

Validation: crystal spec passes (44 examples, 0 failures).

Committed in:

  • e263254 JS-45 Swap browser wrapper namespace to JS::Context

Copy link
Owner

@sbsoftware sbsoftware left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please ensure the file names are swapped as well and the README reflects the latest state.

@sbsoftware-agent
Copy link
Collaborator Author

Done. I aligned file paths/names with the swapped context model and updated docs to match current behavior:

  • Renamed runtime files to src/js/context/* and swapped context.crbrowser.cr (JS::Context::Browser now lives in src/js/context/browser.cr).
  • Updated top-level require to require "./js/context/*" in src/js.cr.
  • Renamed spec path to spec/js/context/console_spec.cr.
  • Updated README wrapper-extension section to JS::Context::* naming and src/js/context/<api>.cr.
  • Removed the outdated checklist line you flagged.

Validation: crystal spec passes (44 examples, 0 failures).

Committed in:

  • b2a8c08 JS-45 Rename context wrapper files and refresh README

README.md Outdated
In strict mode:

- Referencing/calling undeclared JS identifiers raises a compile-time error.
- `js_alias` is the explicit extern declaration escape hatch.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's remove that explicit hint here (and from the strict mode examples). It is not encouraged to use it that way.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. I removed the explicit js_alias hint from the strict-mode bullet list and updated the strict-mode example to no longer use js_alias (it now only shows console.log(...)).

Validation: crystal spec passes (44 examples, 0 failures).

Committed in 3f885cc with message:
JS-45 Remove js_alias strict-mode guidance from README

@sbsoftware sbsoftware merged commit fc9befe into releases/v1.8.0 Feb 14, 2026
@sbsoftware sbsoftware deleted the feature/JS-45 branch February 25, 2026 17:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants