ruact 0.0.4 → 0.0.6

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.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +54 -2
  3. data/.rubocop_todo.yml +3 -115
  4. data/CHANGELOG.md +68 -17
  5. data/bench/server_functions_dispatch_bench.rb +109 -142
  6. data/bench/server_functions_dispatch_bench.results.md +29 -0
  7. data/docs/internal/decisions/server-functions-api.md +402 -0
  8. data/lib/generators/ruact/install/install_generator.rb +310 -25
  9. data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
  10. data/lib/generators/ruact/install/templates/dev.tt +16 -0
  11. data/lib/generators/ruact/install/templates/package.json.tt +17 -0
  12. data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
  13. data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
  14. data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
  15. data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
  16. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
  17. data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
  18. data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
  19. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
  20. data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
  21. data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
  22. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
  23. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
  24. data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
  25. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
  26. data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
  27. data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
  28. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
  29. data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
  30. data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
  31. data/lib/ruact/client_manifest.rb +37 -36
  32. data/lib/ruact/component_contract.rb +115 -0
  33. data/lib/ruact/configuration.rb +80 -28
  34. data/lib/ruact/controller.rb +69 -421
  35. data/lib/ruact/doctor.rb +133 -4
  36. data/lib/ruact/erb_preprocessor.rb +65 -12
  37. data/lib/ruact/erb_preprocessor_hook.rb +4 -1
  38. data/lib/ruact/errors.rb +30 -44
  39. data/lib/ruact/html_converter.rb +22 -1
  40. data/lib/ruact/railtie.rb +56 -200
  41. data/lib/ruact/server.rb +28 -6
  42. data/lib/ruact/server_functions/codegen.rb +49 -188
  43. data/lib/ruact/server_functions/codegen_v2.rb +23 -6
  44. data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
  45. data/lib/ruact/server_functions/error_payload.rb +1 -1
  46. data/lib/ruact/server_functions/error_rendering.rb +22 -29
  47. data/lib/ruact/server_functions/name_bridge.rb +14 -16
  48. data/lib/ruact/server_functions/query_source.rb +35 -8
  49. data/lib/ruact/server_functions/route_source.rb +3 -4
  50. data/lib/ruact/server_functions/snapshot.rb +12 -139
  51. data/lib/ruact/server_functions/validation_errors.rb +70 -0
  52. data/lib/ruact/server_functions.rb +21 -25
  53. data/lib/ruact/signed_references.rb +162 -0
  54. data/lib/ruact/string_distance.rb +72 -0
  55. data/lib/ruact/validation_errors_collector.rb +139 -0
  56. data/lib/ruact/version.rb +1 -1
  57. data/lib/ruact/view_helper.rb +102 -0
  58. data/lib/ruact.rb +19 -19
  59. data/lib/tasks/ruact.rake +10 -53
  60. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
  61. data/spec/ruact/client_manifest_spec.rb +36 -0
  62. data/spec/ruact/component_contract_spec.rb +119 -0
  63. data/spec/ruact/configuration_spec.rb +51 -34
  64. data/spec/ruact/controller_request_spec.rb +264 -0
  65. data/spec/ruact/controller_spec.rb +63 -326
  66. data/spec/ruact/doctor_spec.rb +201 -0
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
  68. data/spec/ruact/erb_preprocessor_spec.rb +127 -0
  69. data/spec/ruact/errors_spec.rb +0 -45
  70. data/spec/ruact/html_converter_spec.rb +50 -0
  71. data/spec/ruact/install_generator_spec.rb +591 -4
  72. data/spec/ruact/query_request_spec.rb +109 -1
  73. data/spec/ruact/scaffold_generator_spec.rb +1835 -0
  74. data/spec/ruact/server_bucket_request_spec.rb +142 -0
  75. data/spec/ruact/server_function_name_spec.rb +1 -1
  76. data/spec/ruact/server_functions/codegen_spec.rb +158 -269
  77. data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
  78. data/spec/ruact/server_functions/query_source_spec.rb +51 -0
  79. data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
  80. data/spec/ruact/server_functions/rake_spec.rb +29 -29
  81. data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
  82. data/spec/ruact/server_rescue_request_spec.rb +6 -6
  83. data/spec/ruact/server_spec.rb +8 -9
  84. data/spec/ruact/signed_references_spec.rb +164 -0
  85. data/spec/ruact/string_distance_spec.rb +38 -0
  86. data/spec/ruact/validation_errors_spec.rb +116 -0
  87. data/spec/ruact/view_helper_spec.rb +79 -0
  88. data/spec/spec_helper.rb +0 -5
  89. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +44 -47
  90. data/vendor/javascript/ruact-server-functions-runtime/index.js +151 -107
  91. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  92. data/vendor/javascript/ruact-server-functions-runtime/package.json +2 -2
  93. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +187 -0
  94. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  95. data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
  96. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  97. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  98. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  99. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  100. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  101. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  102. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  103. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  104. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  105. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  106. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  107. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  108. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  109. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  110. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  118. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  119. metadata +55 -15
  120. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  121. data/lib/ruact/server_action.rb +0 -131
  122. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  123. data/lib/ruact/server_functions/registry.rb +0 -148
  124. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  125. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  126. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  127. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  128. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  129. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  130. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  131. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  132. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
@@ -0,0 +1,96 @@
1
+ // Story 14.2 (FR104) — the bootstrap virtual module.
2
+ //
3
+ // ruact's React entry is no longer a `app/javascript/application.jsx` file in
4
+ // the user's tree; it is served as the virtual module `virtual:ruact/bootstrap`
5
+ // from gem-shipped source (`runtime/bootstrap.jsx`), mirroring the existing
6
+ // `virtual:ruact/registry` pattern. This file covers the plugin contract:
7
+ //
8
+ // 1. resolveId maps the public id → the `\0`-prefixed resolved id.
9
+ // 2. load serves the bootstrap source, importing `virtual:ruact/registry` +
10
+ // React (from the app root → single React instance) + the runtime modules
11
+ // via ABSOLUTE fs specifiers (relative imports do not resolve from a
12
+ // `\0virtual:` id).
13
+ // 3. generateBootstrapSource rewrites ONLY the import specifiers (not prose
14
+ // mentions in comments) and emits no JSX (loads via Vite's default loader).
15
+
16
+ import { describe, it, expect } from "vitest";
17
+ import fs from "node:fs";
18
+ import os from "node:os";
19
+ import path from "node:path";
20
+ import ruact, {
21
+ BOOTSTRAP_VIRTUAL_ID,
22
+ REGISTRY_VIRTUAL_ID,
23
+ generateBootstrapSource,
24
+ } from "./index.js";
25
+
26
+ const RESOLVED = "\0" + BOOTSTRAP_VIRTUAL_ID;
27
+
28
+ describe("virtual:ruact/bootstrap — resolveId (Story 14.2)", () => {
29
+ it("resolves the public id to the \\0-prefixed resolved id", () => {
30
+ const plugin = ruact();
31
+ expect(plugin.resolveId(BOOTSTRAP_VIRTUAL_ID)).toBe(RESOLVED);
32
+ });
33
+
34
+ it("returns null for unrelated ids (and leaves the registry id intact)", () => {
35
+ const plugin = ruact();
36
+ expect(plugin.resolveId("react")).toBeNull();
37
+ // The bootstrap branch must not regress the registry branch.
38
+ expect(plugin.resolveId(REGISTRY_VIRTUAL_ID)).toBe("\0" + REGISTRY_VIRTUAL_ID);
39
+ });
40
+ });
41
+
42
+ describe("virtual:ruact/bootstrap — load (Story 14.2)", () => {
43
+ const plugin = ruact();
44
+ const src = plugin.load(RESOLVED);
45
+
46
+ it("imports virtual:ruact/registry (10.1b auto-registry preserved)", () => {
47
+ expect(src).toContain("from 'virtual:ruact/registry'");
48
+ });
49
+
50
+ it("imports React and react-dom/client as BARE specifiers (single React from app root)", () => {
51
+ expect(src).toMatch(/from 'react'/);
52
+ expect(src).toContain("from 'react-dom/client'");
53
+ });
54
+
55
+ it("imports the runtime modules via ABSOLUTE fs specifiers, not relative", () => {
56
+ expect(src).toMatch(/from '\/.*\/runtime\/flight-client\.js'/);
57
+ expect(src).toMatch(/from '\/.*\/runtime\/ruact-router\.js'/);
58
+ // No `\0virtual:`-unresolvable relative import survives.
59
+ expect(src).not.toMatch(/from\s+['"]\.\/flight-client\.js['"]/);
60
+ expect(src).not.toMatch(/from\s+['"]\.\/ruact-router\.js['"]/);
61
+ });
62
+
63
+ it("contains no JSX syntax (loads via the default loader like the registry)", () => {
64
+ expect(src).not.toContain("<App");
65
+ expect(src).toContain("createElement(App)");
66
+ });
67
+
68
+ it("returns null for the registry id via the same load hook (no cross-wiring)", () => {
69
+ // The registry load still works; the bootstrap branch is additive.
70
+ expect(plugin.load("\0" + REGISTRY_VIRTUAL_ID)).toContain("MODULE_REGISTRY");
71
+ });
72
+ });
73
+
74
+ describe("generateBootstrapSource — rewrites imports only (Story 14.2)", () => {
75
+ it("rewrites the runtime import specifiers but leaves a prose mention untouched", () => {
76
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "ruact-boot-")));
77
+ // A bootstrap whose COMMENT mentions ./flight-client.js and whose IMPORT
78
+ // uses it — only the import specifier must be rewritten.
79
+ fs.writeFileSync(
80
+ path.join(dir, "bootstrap.jsx"),
81
+ [
82
+ "// reads ./flight-client.js at boot",
83
+ "import { createFromFlightPayload } from './flight-client.js';",
84
+ "import { setupRouter } from './ruact-router.js';",
85
+ "export const ok = true;",
86
+ ].join("\n"),
87
+ );
88
+ const out = generateBootstrapSource(dir);
89
+ const fc = path.join(dir, "flight-client.js").replace(/\\/g, "/");
90
+ expect(out).toContain(`from '${fc}'`);
91
+ // The comment's bare mention is preserved (not a `from '...'` import).
92
+ expect(out).toContain("// reads ./flight-client.js at boot");
93
+ expect(out).not.toMatch(/from\s+['"]\.\/flight-client\.js['"]/);
94
+ fs.rmSync(dir, { recursive: true, force: true });
95
+ });
96
+ });
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
3
4
  import { installServerFunctionsHooks } from "./server-functions-codegen.mjs";
4
5
 
5
6
  /**
@@ -17,7 +18,51 @@ import { installServerFunctionsHooks } from "./server-functions-codegen.mjs";
17
18
  * "chunks": ["/assets/LikeButton-abc123.js"]
18
19
  * }
19
20
  * }
21
+ *
22
+ * Story 13.5 (FR100) — compile-time component contract. A component opts into a
23
+ * call-site contract by exporting `__ruactContract` from its own module
24
+ * (HEEx-style `attr`/`slot`, declared next to the component):
25
+ *
26
+ * export const __ruactContract = {
27
+ * props: { title: "required", subtitle: "optional" },
28
+ * slots: { header: "optional" }, // optional; { name: required|optional } or ["name", ...]
29
+ * passthrough: false, // optional; true allows undeclared props
30
+ * };
31
+ *
32
+ * The scanner extracts this NAMES-ONLY (no TS-AST, no value types) into an
33
+ * optional `contract` field on the manifest entry. A component without the
34
+ * export emits no `contract` field (byte-additive, back-compatible). A
35
+ * malformed/partial declaration is warned + skipped (the Ruby side then sees
36
+ * "no contract" and validates nothing — fail open). The Ruby preprocess-time
37
+ * validator (`Ruact::ComponentContract`) reads this and checks `<Component .../>`
38
+ * ERB call sites for missing-required / unknown-prop / slot-misuse before render.
20
39
  */
40
+ // Story 10.1b — the auto-registry virtual module. The install template (and the
41
+ // playgrounds) import this instead of hand-maintaining a `MODULE_REGISTRY`. Its
42
+ // value is `{ [manifest id]: moduleExports }`, derived from the SAME scan that
43
+ // writes react-client-manifest.json, so registry membership == manifest
44
+ // membership and the keys equal the manifest `id` the gem serializes as the
45
+ // Flight `moduleId` (client_manifest.rb:79) — by construction, in dev AND prod.
46
+ export const REGISTRY_VIRTUAL_ID = "virtual:ruact/registry";
47
+ const RESOLVED_REGISTRY_ID = "\0" + REGISTRY_VIRTUAL_ID;
48
+
49
+ // Story 14.2 (FR104) — the bootstrap virtual module. The React entry that boots
50
+ // the app used to be written into every app as `app/javascript/application.jsx`
51
+ // (ruact plumbing interleaved with the user's components). It is now served as a
52
+ // virtual module from gem-shipped source (`runtime/bootstrap.jsx`), so a fresh
53
+ // install leaves `app/javascript/` with ONLY the user's `components/`. The
54
+ // generated `vite.config` input is `virtual:ruact/bootstrap`; the gem's
55
+ // `ruact_js_assets` view helper targets the same id (dev `<script src>` →
56
+ // `/@id/__x00__virtual:ruact/bootstrap`; prod Vite-manifest key
57
+ // `virtual:ruact/bootstrap`). Mirrors REGISTRY_VIRTUAL_ID exactly.
58
+ export const BOOTSTRAP_VIRTUAL_ID = "virtual:ruact/bootstrap";
59
+ const RESOLVED_BOOTSTRAP_ID = "\0" + BOOTSTRAP_VIRTUAL_ID;
60
+
61
+ // The gem-shipped runtime sources the virtual bootstrap pulls in. Resolved
62
+ // against THIS file so the absolute specifiers the bootstrap imports always
63
+ // point at the gem copy, regardless of the app's cwd.
64
+ const RUNTIME_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "runtime");
65
+
21
66
  export default function ruact(options = {}) {
22
67
  const {
23
68
  componentsDir = "app/javascript/components",
@@ -34,6 +79,26 @@ export default function ruact(options = {}) {
34
79
  root = config.root;
35
80
  },
36
81
 
82
+ // Story 10.1b / 14.2 — resolve ruact's virtual module ids.
83
+ resolveId(id) {
84
+ if (id === REGISTRY_VIRTUAL_ID) return RESOLVED_REGISTRY_ID;
85
+ if (id === BOOTSTRAP_VIRTUAL_ID) return RESOLVED_BOOTSTRAP_ID;
86
+ return null;
87
+ },
88
+
89
+ // Story 10.1b — emit the registry source from the in-memory manifest. The
90
+ // manifest is populated in `buildStart` (and rebuilt by the dev watcher
91
+ // below), which Rollup/Vite run before module loading, so it is ready here
92
+ // in both `build` and `serve`. Keys are the manifest `id` values verbatim
93
+ // (no re-normalization → zero drift with what the gem resolves).
94
+ load(id) {
95
+ if (id === RESOLVED_REGISTRY_ID) return generateRegistrySource(manifest);
96
+ // Story 14.2 — serve the gem-shipped bootstrap source, with its relative
97
+ // runtime imports rewritten to absolute fs specifiers (see below).
98
+ if (id === RESOLVED_BOOTSTRAP_ID) return generateBootstrapSource();
99
+ return null;
100
+ },
101
+
37
102
  // During dev: build the manifest from source files
38
103
  buildStart() {
39
104
  manifest = buildManifest(path.resolve(root, componentsDir));
@@ -59,10 +124,36 @@ export default function ruact(options = {}) {
59
124
  name,
60
125
  chunks: [url],
61
126
  };
127
+ // Story 13.5 — preserve the opt-in contract across the dev→build
128
+ // rewrite (the hashed-URL pass must not drop it).
129
+ if (entry.contract) updated[name].contract = entry.contract;
62
130
  }
63
131
  }
64
132
  }
65
133
 
134
+ // Story 10.1b — id-match guard (NFR16 dev/prod parity). The auto-registry
135
+ // (virtual:ruact/registry) is keyed on the source-relative manifest id and
136
+ // is loaded BEFORE this rewrite. If a "use client" component became a
137
+ // standalone facade chunk (e.g. it was added to build.rollupOptions.input,
138
+ // or split via manualChunks), its id is rewritten to a hashed URL here
139
+ // while the registry still resolves it by source path — a SILENT prod
140
+ // hydration miss. Fail the build loudly instead. In the shipped model
141
+ // components are imported by the registry and inlined into the app bundle,
142
+ // so they never become facade chunks and this never fires.
143
+ const rewritten = Object.keys(updated).filter(
144
+ (name) => manifest[name] && updated[name].id !== manifest[name].id
145
+ );
146
+ if (rewritten.length > 0) {
147
+ throw new Error(
148
+ `[vite-plugin-ruact] component(s) ${rewritten.join(", ")} were emitted as ` +
149
+ "standalone (code-split) chunks, so the Flight manifest names them by a " +
150
+ "hashed URL while virtual:ruact/registry resolves them by source path — a " +
151
+ "silent hydration miss in production. Keep \"use client\" components OUT of " +
152
+ "build.rollupOptions.input and avoid manualChunks for them: they are meant " +
153
+ "to be imported by the auto-registry and inlined into the app bundle."
154
+ );
155
+ }
156
+
66
157
  // Merge: keep entries that didn't get a hashed URL (dev mode)
67
158
  const final = { ...manifest, ...updated };
68
159
  // Strip internal _sourceFile field
@@ -73,21 +164,31 @@ export default function ruact(options = {}) {
73
164
  writeManifest(path.resolve(root, manifestOutput), final);
74
165
  },
75
166
 
76
- // Dev server: watch components dir and rebuild manifest on change
167
+ // Dev server: watch components dir and rebuild manifest on change. Story
168
+ // 10.1b — also react to add/unlink and invalidate the auto-registry virtual
169
+ // module so a newly added (or removed) "use client" component is registered
170
+ // with ZERO app-code edits (AC1, dev side).
77
171
  configureServer(server) {
78
172
  const dir = path.resolve(root, componentsDir);
79
173
  server.watcher.add(dir);
80
- server.watcher.on("change", (file) => {
81
- if (file.startsWith(dir)) {
82
- manifest = buildManifest(dir);
83
- writeManifest(path.resolve(root, manifestOutput), manifest);
174
+ const rebuild = (file) => {
175
+ if (!file.startsWith(dir)) return;
176
+ manifest = buildManifest(dir);
177
+ writeManifest(path.resolve(root, manifestOutput), manifest);
178
+ const mod = server.moduleGraph.getModuleById(RESOLVED_REGISTRY_ID);
179
+ if (mod) {
180
+ server.moduleGraph.invalidateModule(mod);
181
+ server.ws.send({ type: "full-reload" });
84
182
  }
85
- });
183
+ };
184
+ server.watcher.on("change", rebuild);
185
+ server.watcher.on("add", rebuild);
186
+ server.watcher.on("unlink", rebuild);
86
187
  },
87
188
  }, options);
88
189
  }
89
190
 
90
- function buildManifest(componentsDir) {
191
+ export function buildManifest(componentsDir) {
91
192
  const manifest = {};
92
193
 
93
194
  if (!fs.existsSync(componentsDir)) return manifest;
@@ -102,6 +203,22 @@ function buildManifest(componentsDir) {
102
203
 
103
204
  const exports = extractExportNames(content);
104
205
  const relUrl = "/" + path.relative(componentsDir, file);
206
+ let contract = extractContract(content, file);
207
+
208
+ // Story 13.5 — a single `__ruactContract` cannot say WHICH component it
209
+ // describes, so it only applies when the file has exactly one component
210
+ // export (the documented one-component-per-file convention). A multi-export
211
+ // file would otherwise validate every export against the same contract —
212
+ // warn + skip rather than guess.
213
+ if (contract && exports.length > 1) {
214
+ // eslint-disable-next-line no-console
215
+ console.warn(
216
+ `[vite-plugin-ruact] ignoring __ruactContract in ${file}: a contract ` +
217
+ `applies to a single component, but this file exports ${exports.length} ` +
218
+ `(${exports.join(", ")}). Split them into one component per file.`
219
+ );
220
+ contract = null;
221
+ }
105
222
 
106
223
  for (const name of exports) {
107
224
  manifest[name] = {
@@ -110,12 +227,93 @@ function buildManifest(componentsDir) {
110
227
  chunks: [relUrl],
111
228
  _sourceFile: file, // used during build to match hashed chunks
112
229
  };
230
+ // Story 13.5 — opt-in, byte-additive: only present when declared.
231
+ if (contract) manifest[name].contract = contract;
113
232
  }
114
233
  }
115
234
 
116
235
  return manifest;
117
236
  }
118
237
 
238
+ // Story 10.1b (AC1, AC2) — render the auto-registry virtual module source from a
239
+ // manifest (the same object `buildManifest` produces). The result is an ESM
240
+ // module whose default export is `{ [manifest id]: moduleNamespace }`:
241
+ //
242
+ // import * as __ruact_m0 from "/abs/app/javascript/components/PostList.jsx";
243
+ // const MODULE_REGISTRY = { "/PostList.jsx": __ruact_m0 };
244
+ // export default MODULE_REGISTRY;
245
+ //
246
+ // Why keyed on the manifest `id` (not a re-derived path): the gem serializes
247
+ // `entry["id"]` as the Flight `moduleId`, and the client resolves
248
+ // `moduleRegistry[row.moduleId]` — so keying the registry on the very same
249
+ // `id` makes the id-match invariant hold BY CONSTRUCTION. The shipped runtime
250
+ // loads components from this eager registry (the Flight client ignores the
251
+ // Import row's `chunks`), so each component is statically imported and inlined
252
+ // into the app bundle; it never becomes a standalone facade chunk, and
253
+ // `generateBundle`'s hashed-URL rewrite (which only fires for facade chunks)
254
+ // leaves the component `id` as its source-relative path in prod exactly as in
255
+ // dev. NFR16 dev/prod parity therefore holds with one source-relative key set.
256
+ //
257
+ // One import per source file (a file may export several components sharing an
258
+ // `id`/`_sourceFile`); membership equals manifest membership because we iterate
259
+ // the manifest the scan built ("use client"-only, `.jsx`/`.tsx`, same dir).
260
+ export function generateRegistrySource(manifest) {
261
+ const byId = new Map(); // manifest id -> absolute source file
262
+ for (const entry of Object.values(manifest || {})) {
263
+ if (!entry || !entry._sourceFile || !entry.id) continue;
264
+ if (!byId.has(entry.id)) byId.set(entry.id, entry._sourceFile);
265
+ }
266
+
267
+ const lines = [
268
+ "// AUTO-GENERATED by vite-plugin-ruact — component auto-registry (Story 10.1b).",
269
+ "// Maps each react-client-manifest `id` to its module exports. Do not edit.",
270
+ ];
271
+ const props = [];
272
+ let i = 0;
273
+ for (const [id, sourceFile] of byId) {
274
+ const local = `__ruact_m${i++}`;
275
+ lines.push(`import * as ${local} from ${JSON.stringify(toImportSpecifier(sourceFile))};`);
276
+ props.push(` ${JSON.stringify(id)}: ${local},`);
277
+ }
278
+ lines.push("const MODULE_REGISTRY = {", ...props, "};", "export default MODULE_REGISTRY;", "");
279
+
280
+ return lines.join("\n");
281
+ }
282
+
283
+ // Story 14.2 (FR104) — render the virtual bootstrap source from the gem-shipped
284
+ // `runtime/bootstrap.jsx`. A `load` hook returns module TEXT whose relative
285
+ // imports resolve against the resolved id (`\0virtual:ruact/bootstrap`), which
286
+ // is NOT a filesystem path — so `./flight-client.js` / `./ruact-router.js`
287
+ // would fail. We rewrite those two specifiers to ABSOLUTE fs specifiers into the
288
+ // gem `runtime/` dir (the same technique `generateRegistrySource` uses via
289
+ // `toImportSpecifier`). The bare `react` / `react-dom/client` specifiers and the
290
+ // `virtual:ruact/registry` id are left untouched — Vite resolves them from the
291
+ // app root (so React comes from the USER's node_modules: one React instance).
292
+ export function generateBootstrapSource(runtimeDir = RUNTIME_DIR) {
293
+ const src = fs.readFileSync(path.join(runtimeDir, "bootstrap.jsx"), "utf8");
294
+ const abs = (name) => toImportSpecifier(path.join(runtimeDir, name));
295
+ // Rewrite EVERY `from './<runtime>.js'` import specifier to its absolute fs
296
+ // path. Matching the quoted `from '...'` form (not the bare filename) avoids
297
+ // hitting prose mentions of the file in comments, and the global regex
298
+ // tolerates either quote style. The replacement is supplied as a function so a
299
+ // `$` in the absolute path is never treated as a replacement pattern.
300
+ const rewrite = (code, name) => {
301
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
302
+ return code.replace(
303
+ new RegExp(`from\\s+(['"])\\./${escaped}\\1`, "g"),
304
+ () => `from '${abs(name)}'`,
305
+ );
306
+ };
307
+ return rewrite(rewrite(src, "flight-client.js"), "ruact-router.js");
308
+ }
309
+
310
+ // A bundler import specifier for an absolute fs path. Vite/Rollup resolve
311
+ // absolute paths directly; normalize Windows backslashes to forward slashes so
312
+ // the emitted specifier is a valid module string on every platform.
313
+ function toImportSpecifier(absPath) {
314
+ return absPath.replace(/\\/g, "/");
315
+ }
316
+
119
317
  function hasUseClient(content) {
120
318
  // "use client" must appear as a directive at the top of the file
121
319
  return /^\s*["']use client["']/m.test(content);
@@ -145,6 +343,154 @@ function extractExportNames(content) {
145
343
  return Array.from(names);
146
344
  }
147
345
 
346
+ // Story 13.5 (FR100) — extract the opt-in `__ruactContract` declaration.
347
+ //
348
+ // NAMES-ONLY by design (no TS-AST, no value types): the Ruby preprocess-time
349
+ // validator only needs prop/slot names + required/optional. We locate the
350
+ // exported object literal, balance its braces, then pull names out of the
351
+ // `props` / `slots` sub-blocks with targeted regexes (robust to formatting,
352
+ // and immune to eval). Returns:
353
+ // - null when the component declares no contract (byte-additive opt-out)
354
+ // - null when the declaration is malformed/empty (warn + skip → Ruby fails open)
355
+ // - { props?, slots?, passthrough? } otherwise (only non-empty members present)
356
+ export function extractContract(content, file) {
357
+ const marker = /export\s+(?:default\s+)?(?:const|let|var)\s+__ruactContract\s*=\s*/m;
358
+ const m = marker.exec(content);
359
+ if (!m) return null;
360
+
361
+ const braceStart = content.indexOf("{", m.index + m[0].length);
362
+ if (braceStart === -1) return warnSkip(file);
363
+
364
+ const objText = extractBalanced(content, braceStart, "{", "}");
365
+ if (objText === null) return warnSkip(file);
366
+
367
+ const contract = {};
368
+
369
+ const props = extractRequiredness(extractBlock(objText, "props", "{", "}"));
370
+ if (props && Object.keys(props).length) contract.props = props;
371
+
372
+ const slots = extractRequiredness(extractBlock(objText, "slots", "{", "}"));
373
+ if (slots && Object.keys(slots).length) {
374
+ contract.slots = slots;
375
+ } else {
376
+ // Array form: slots: ["header", "footer"] → all optional.
377
+ const arr = extractBlock(objText, "slots", "[", "]");
378
+ if (arr !== null) {
379
+ const names = {};
380
+ const nameRe = /["'`]([^"'`]+)["'`]/g;
381
+ let s;
382
+ while ((s = nameRe.exec(arr)) !== null) names[s[1]] = "optional";
383
+ if (Object.keys(names).length) contract.slots = names;
384
+ }
385
+ }
386
+
387
+ if (/\bpassthrough\s*:\s*true\b/.test(objText)) contract.passthrough = true;
388
+
389
+ // A contract with no props, no slots, and no passthrough carries no
390
+ // information — treat as malformed/empty and fail open.
391
+ if (!contract.props && !contract.slots && !contract.passthrough) {
392
+ return warnSkip(file);
393
+ }
394
+
395
+ return contract;
396
+ }
397
+
398
+ // Parse a `{ name: "required", other: "optional" }` block into a
399
+ // { name: "required" | "optional" } map. Accepts quoted or bare values, and an
400
+ // object form `name: { required: true }`. Returns {} for an empty block.
401
+ function extractRequiredness(block) {
402
+ if (block === null) return null;
403
+ const map = {};
404
+ // name: "required" | 'optional' | required (string or bare identifier)
405
+ const strRe = /([A-Za-z_$][\w$]*)\s*:\s*["'`]?(required|optional)["'`]?/g;
406
+ let m;
407
+ while ((m = strRe.exec(block)) !== null) map[m[1]] = m[2];
408
+ // name: { required: true } → required; { required: false } → optional
409
+ const objRe = /([A-Za-z_$][\w$]*)\s*:\s*\{[^}]*?\brequired\s*:\s*(true|false)[^}]*?\}/g;
410
+ while ((m = objRe.exec(block)) !== null) {
411
+ map[m[1]] = m[2] === "true" ? "required" : "optional";
412
+ }
413
+ return map;
414
+ }
415
+
416
+ // Extract the balanced inner text of `key: <open> ... <close>` from `objText`.
417
+ // Returns the inner text (without the delimiters) or null when the key is
418
+ // absent or the delimiters are unbalanced.
419
+ function extractBlock(objText, key, open, close) {
420
+ const keyRe = new RegExp(`\\b${key}\\s*:\\s*\\${open}`, "m");
421
+ const km = keyRe.exec(objText);
422
+ if (!km) return null;
423
+ const start = km.index + km[0].length - 1; // position of `open`
424
+ const inner = extractBalanced(objText, start, open, close);
425
+ return inner;
426
+ }
427
+
428
+ // Given a string and the index of an opening delimiter, return the inner text
429
+ // up to (excluding) the matching close delimiter, or null when unbalanced.
430
+ // String literals ('...', "...", `...`) and comments (// and / * ... * /) are
431
+ // skipped so a brace inside a comment or string never throws off the balance.
432
+ function extractBalanced(str, openIndex, open, close) {
433
+ let depth = 0;
434
+ let i = openIndex;
435
+ while (i < str.length) {
436
+ const ch = str[i];
437
+ const next = str[i + 1];
438
+
439
+ if (ch === "/" && next === "/") {
440
+ const nl = str.indexOf("\n", i);
441
+ if (nl === -1) return null;
442
+ i = nl + 1;
443
+ continue;
444
+ }
445
+ if (ch === "/" && next === "*") {
446
+ const end = str.indexOf("*/", i + 2);
447
+ if (end === -1) return null;
448
+ i = end + 2;
449
+ continue;
450
+ }
451
+ if (ch === '"' || ch === "'" || ch === "`") {
452
+ i = skipString(str, i);
453
+ if (i === -1) return null;
454
+ continue;
455
+ }
456
+
457
+ if (ch === open) {
458
+ depth++;
459
+ } else if (ch === close) {
460
+ depth--;
461
+ if (depth === 0) return str.slice(openIndex + 1, i);
462
+ }
463
+ i++;
464
+ }
465
+ return null;
466
+ }
467
+
468
+ // Skip a JS string/template literal starting at the opening quote at +i+.
469
+ // Returns the index just past the closing quote, or -1 if unterminated.
470
+ function skipString(str, i) {
471
+ const quote = str[i];
472
+ i++;
473
+ while (i < str.length) {
474
+ const ch = str[i];
475
+ if (ch === "\\") {
476
+ i += 2;
477
+ continue;
478
+ }
479
+ if (ch === quote) return i + 1;
480
+ i++;
481
+ }
482
+ return -1;
483
+ }
484
+
485
+ function warnSkip(file) {
486
+ // eslint-disable-next-line no-console
487
+ console.warn(
488
+ `[vite-plugin-ruact] ignoring malformed __ruactContract in ${file} ` +
489
+ `(could not extract prop/slot names — the component will not be contract-validated)`
490
+ );
491
+ return null;
492
+ }
493
+
148
494
  function writeManifest(outputPath, manifest) {
149
495
  fs.mkdirSync(path.dirname(outputPath), { recursive: true });
150
496
  fs.writeFileSync(outputPath, JSON.stringify(manifest, null, 2));