Skip to content

Commit 7b068aa

Browse files
authored
Support transforming and serving JS files (#27)
* wip: support transforming and serving JS files * adjustments
1 parent f92ce53 commit 7b068aa

15 files changed

Lines changed: 1160 additions & 251 deletions

File tree

README.md

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,11 @@ import { defineConfig } from "vite";
5959
import { resXVitePlugin } from "rescript-x";
6060

6161
export default defineConfig({
62-
plugins: [resXVitePlugin()],
62+
plugins: [
63+
resXVitePlugin({
64+
clientDirs: ["client"],
65+
}),
66+
],
6367
server: {
6468
port: 9000,
6569
},
@@ -326,7 +330,7 @@ GET /assets/logo.svg
326330

327331
### `assets` for assets that do need transformation
328332

329-
If you have assets you'd like transformed by Vite before using, put them in the top level `assets` folder. This could be CSS, images, additional JavaScript, and so on. Anything you might want Vite to transform.
333+
If you have assets you'd like transformed by Vite before using, put them in the top level `assets` folder. This could be CSS, images, or browser entry JavaScript. Anything you might want Vite to transform.
330334

331335
Here's an example of how you wire up Tailwind:
332336

@@ -347,6 +351,81 @@ Then, include it in your ReScript:
347351

348352
There! It's now available to you, and Vite will both transform and hot module reload the asset if it's possible.
349353

354+
#### Thinking about client side JavaScript
355+
356+
ResX is server-first. The default is:
357+
358+
- Render HTML on the server.
359+
- Reach for normal links, forms and handlers first.
360+
- Use HTMX or `ResX.Client` when declarative browser behavior is enough.
361+
- Add your own browser JavaScript only when you actually need code running in the browser.
362+
363+
When you do need browser JavaScript, think in terms of browser entry modules, not loose script files. An entry module is the file you include from HTML. That file can then import whatever else it needs, and Vite will handle transformation, minification, hashing, CSS extraction, and shared chunks in production.
364+
365+
There are two intended places for those entry modules:
366+
367+
- Put small app-local entry files in top level `assets/` when they sit naturally next to your other transformed assets.
368+
- Configure `clientDirs` when you want a dedicated folder for browser code, for example `client/`.
369+
370+
Top level JS and TS files in `assets/` become browser entries automatically. They are exposed through `ResXAssets.assets` and should be loaded as module scripts:
371+
372+
```rescript
373+
<script type_="module" src={ResXAssets.assets.analytics_js} />
374+
```
375+
376+
If you want browser entry files outside `assets/`, configure `clientDirs` in `resXVitePlugin`. Files found there are also exposed through `ResXAssets.assets`, prefixed by directory name:
377+
378+
```js
379+
// vite.config.js
380+
import { defineConfig } from "vite";
381+
import resXVitePlugin from "rescript-x/res-x-vite-plugin.mjs";
382+
383+
export default defineConfig({
384+
plugins: [
385+
resXVitePlugin({
386+
clientDirs: ["client"],
387+
}),
388+
],
389+
});
390+
```
391+
392+
```rescript
393+
<script type_="module" src={ResXAssets.assets.client__admin_ts} />
394+
```
395+
396+
The recommended structure is:
397+
398+
- Keep entry files at the top level of `assets/` or each configured `clientDirs` folder.
399+
- Put shared support modules in subdirectories and import them from those entries.
400+
- Import CSS from the entry module when that CSS belongs to that client behavior.
401+
402+
For example:
403+
404+
```text
405+
assets/
406+
analytics.js
407+
client/
408+
admin.ts
409+
admin.css
410+
shared/
411+
markLoaded.ts
412+
```
413+
414+
```ts
415+
// client/admin.ts
416+
import "./admin.css";
417+
import {markLoaded} from "./shared/markLoaded";
418+
419+
document.body.classList.add("client-admin-loaded");
420+
markLoaded(document.body, "admin-loaded");
421+
```
422+
423+
Any CSS imported from those browser entries is emitted and loaded automatically in both development and production.
424+
425+
By default, only top level JS and TS files in `assets/` and each configured `clientDirs` folder become entries. Put shared support modules in subdirectories and import them from those entries so Vite can emit shared chunks for them. If you want a different discovery rule, set `assetEntryGlobs` and `clientEntryGlobs`.
426+
427+
Current limitation: this pipeline expects browser entries to be JavaScript or TypeScript by the time Vite sees them. Direct `.res` entry files are not part of this flow. If you want to write client code in ReScript, compile it to JS first and then point `clientDirs` or `extraClientEntries` at that generated JS.
428+
350429
#### Referring to transformed `assets`
351430

352431
Notice how we're not using a `"/assets/styles.css"` string to refer to `styles.css`, but rather `ResXAssets.assets.styles_css`? This is because ResX comes with a "type safe" asset layer - anything you put in `assets/` will be available via `ResXAssets.assets`.
@@ -684,10 +763,12 @@ These functions should only be used in exceptional cases where you need to:
684763

685764
ResX also ships with a tiny client side library that will help you do basic client side tasks fully declaratively. It's quite basic at the moment, but will be extended (tastefully) as we discover more places where it can help you avoid having to use a full blown client side framework to accomplish fairly basic tasks.
686765

766+
The browser bundle for this is shipped with `rescript-x`, so you can reference `ResXAssets.assets.resXClient_js` directly without adding your own `extraClientEntries` config.
767+
687768
To use ResX client, make sure you include its script:
688769

689770
```rescript
690-
<script src={ResXAssets.assets.resXClient_js} async=true />
771+
<script type_="module" src={ResXAssets.assets.resXClient_js} async=true />
691772
```
692773

693774
#### Handling CSS classes on events

client/ResXClient.js

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Built browser entry for ResX.Client.
2+
// Source of truth: src/ResXClient.res
3+
// Regenerate with: npm run build:resx-client
4+
5+
function init() {
6+
let getTarget = (target, $$this) => {
7+
if (typeof target !== "object") {
8+
return $$this;
9+
} else {
10+
return document.querySelector(target.selector);
11+
}
12+
};
13+
let handleAction = (action, $$this) => {
14+
let target;
15+
target = action.kind === "CopyToClipboard" ? null : getTarget(action.target, $$this);
16+
if (target === null) {
17+
if (action.kind !== "CopyToClipboard") {
18+
return;
19+
}
20+
let onAfterFailure = action.onAfterFailure;
21+
let onAfterSuccess = action.onAfterSuccess;
22+
navigator.clipboard.writeText(action.text).catch(param => {
23+
if (onAfterFailure !== undefined) {
24+
return Promise.resolve((onAfterFailure.forEach(action => handleAction(action, $$this)), undefined));
25+
} else {
26+
return Promise.resolve();
27+
}
28+
}).then(() => {
29+
if (onAfterSuccess !== undefined) {
30+
onAfterSuccess.forEach(action => handleAction(action, $$this));
31+
return;
32+
}
33+
});
34+
return;
35+
}
36+
switch (action.kind) {
37+
case "ToggleClass" :
38+
action.className.split(" ").forEach(className => target.classList.toggle(className));
39+
return;
40+
case "RemoveClass" :
41+
action.className.split(" ").forEach(className => target.classList.remove(className));
42+
return;
43+
case "AddClass" :
44+
action.className.split(" ").forEach(className => target.classList.add(className));
45+
return;
46+
case "SwapClass" :
47+
action.fromClassName.split(" ").forEach(className => target.classList.remove(className));
48+
action.toClassName.split(" ").forEach(className => target.classList.add(className));
49+
return;
50+
case "RemoveElement" :
51+
return target.remove();
52+
case "CopyToClipboard" :
53+
return;
54+
}
55+
};
56+
document.addEventListener("click", event => {
57+
let $$this = event.target;
58+
let match = $$this.attributes["resx-onclick"];
59+
let actions = match !== undefined ? JSON.parse(match.value) : [];
60+
actions.forEach(action => handleAction(action, $$this));
61+
});
62+
document.addEventListener("invalid", event => {
63+
let $$this = event.target;
64+
let match = $$this.validity;
65+
let match$1 = $$this.attributes["resx-validity-message"];
66+
if (match === undefined) {
67+
return;
68+
}
69+
if (match.valid) {
70+
return;
71+
}
72+
if (match$1 === undefined) {
73+
return;
74+
}
75+
let validityMessages = JSON.parse(match$1.value);
76+
let messageToSet = match.badInput ? validityMessages.badInput : (
77+
match.patternMismatch ? validityMessages.patternMismatch : (
78+
match.rangeOverflow ? validityMessages.rangeOverflow : (
79+
match.rangeUnderflow ? validityMessages.rangeUnderflow : (
80+
match.stepMismatch ? validityMessages.stepMismatch : (
81+
match.tooLong ? validityMessages.tooLong : (
82+
match.tooShort ? validityMessages.tooShort : (
83+
match.typeMismatch ? validityMessages.typeMismatch : (
84+
match.valueMissing ? validityMessages.valueMissing : undefined
85+
)
86+
)
87+
)
88+
)
89+
)
90+
)
91+
)
92+
);
93+
if (messageToSet !== undefined) {
94+
return $$this.setCustomValidity(messageToSet);
95+
}
96+
}, true);
97+
document.addEventListener("change", event => {
98+
let $$this = event.target;
99+
let match = $$this.attributes["resx-validity-message"];
100+
if (match !== undefined) {
101+
return $$this.setCustomValidity("");
102+
}
103+
});
104+
}
105+
106+
init();

demo/assets/analytics.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import {markLoaded} from "../client/shared/markLoaded";
2+
3+
markLoaded(document.documentElement, "analytics-loaded");

demo/client/admin.css

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.client-admin-loaded {
2+
box-shadow: inset 0 0 0 4px #0f766e;
3+
}

demo/client/admin.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import "./admin.css";
2+
import {markLoaded} from "./shared/markLoaded";
3+
4+
document.body.classList.add("client-admin-loaded");
5+
markLoaded(document.body, "admin-loaded");

demo/client/shared/markLoaded.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
const sharedState = window as typeof window & {
2+
__resxMarkLoadedModuleCount?: number;
3+
};
4+
5+
sharedState.__resxMarkLoadedModuleCount =
6+
(sharedState.__resxMarkLoadedModuleCount || 0) + 1;
7+
8+
export function markLoaded(node: HTMLElement, value: string) {
9+
node.dataset.sharedLoaded = value;
10+
}

demo/src/Html.js

Lines changed: 10 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

demo/src/Html.res

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ let make = (~children) => {
88
{children}
99
<ResX.Dev />
1010
<script src="https://unpkg.com/htmx.org@1.9.5" />
11-
<script src={ResXAssets.assets.resXClient_js} async=true />
11+
<script type_="module" src={ResXAssets.assets.analytics_js} />
12+
<script type_="module" src={ResXAssets.assets.client__admin_ts} />
13+
<script type_="module" src={ResXAssets.assets.resXClient_js} async=true />
1214
</body>
1315
</html>
1416
}

demo/src/__generated__/ResXAssets.res

Lines changed: 16 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

demo/vite.config.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import { defineConfig } from "vite";
22
import resXVitePlugin from "../res-x-vite-plugin.mjs";
33

44
export default defineConfig({
5-
plugins: [resXVitePlugin()],
5+
plugins: [
6+
resXVitePlugin({
7+
clientDirs: ["client"],
8+
}),
9+
],
610
server: {
711
port: 9000,
812
},

0 commit comments

Comments
 (0)