ruact 0.0.5 → 0.0.7

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 (135) 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 +74 -18
  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 +79 -425
  35. data/lib/ruact/doctor.rb +133 -4
  36. data/lib/ruact/erb_preprocessor.rb +71 -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/manifest_resolver.rb +149 -0
  41. data/lib/ruact/railtie.rb +70 -203
  42. data/lib/ruact/render_pipeline.rb +8 -1
  43. data/lib/ruact/server.rb +28 -6
  44. data/lib/ruact/server_functions/codegen.rb +49 -188
  45. data/lib/ruact/server_functions/codegen_v2.rb +23 -6
  46. data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
  47. data/lib/ruact/server_functions/error_payload.rb +1 -1
  48. data/lib/ruact/server_functions/error_rendering.rb +22 -29
  49. data/lib/ruact/server_functions/name_bridge.rb +14 -16
  50. data/lib/ruact/server_functions/query_source.rb +35 -8
  51. data/lib/ruact/server_functions/route_source.rb +3 -4
  52. data/lib/ruact/server_functions/snapshot.rb +12 -139
  53. data/lib/ruact/server_functions/validation_errors.rb +70 -0
  54. data/lib/ruact/server_functions.rb +21 -25
  55. data/lib/ruact/signed_references.rb +162 -0
  56. data/lib/ruact/string_distance.rb +72 -0
  57. data/lib/ruact/validation_errors_collector.rb +139 -0
  58. data/lib/ruact/version.rb +1 -1
  59. data/lib/ruact/view_helper.rb +109 -0
  60. data/lib/ruact.rb +20 -19
  61. data/lib/tasks/ruact.rake +10 -53
  62. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
  63. data/spec/ruact/client_manifest_spec.rb +36 -0
  64. data/spec/ruact/component_contract_spec.rb +119 -0
  65. data/spec/ruact/configuration_spec.rb +51 -34
  66. data/spec/ruact/controller_request_spec.rb +264 -0
  67. data/spec/ruact/controller_spec.rb +70 -328
  68. data/spec/ruact/doctor_spec.rb +201 -0
  69. data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
  70. data/spec/ruact/erb_preprocessor_spec.rb +127 -0
  71. data/spec/ruact/errors_spec.rb +0 -45
  72. data/spec/ruact/html_converter_spec.rb +50 -0
  73. data/spec/ruact/install_generator_spec.rb +591 -4
  74. data/spec/ruact/manifest_resolver_spec.rb +159 -0
  75. data/spec/ruact/query_request_spec.rb +109 -1
  76. data/spec/ruact/scaffold_generator_spec.rb +1835 -0
  77. data/spec/ruact/server_bucket_request_spec.rb +142 -0
  78. data/spec/ruact/server_function_name_spec.rb +1 -1
  79. data/spec/ruact/server_functions/codegen_spec.rb +158 -269
  80. data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
  81. data/spec/ruact/server_functions/query_source_spec.rb +51 -0
  82. data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
  83. data/spec/ruact/server_functions/rake_spec.rb +29 -29
  84. data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
  85. data/spec/ruact/server_rescue_request_spec.rb +6 -6
  86. data/spec/ruact/server_spec.rb +8 -9
  87. data/spec/ruact/signed_references_spec.rb +164 -0
  88. data/spec/ruact/string_distance_spec.rb +38 -0
  89. data/spec/ruact/validation_errors_spec.rb +116 -0
  90. data/spec/ruact/view_helper_spec.rb +79 -0
  91. data/spec/spec_helper.rb +15 -5
  92. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +40 -45
  93. data/vendor/javascript/ruact-server-functions-runtime/index.js +42 -98
  94. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  95. data/vendor/javascript/ruact-server-functions-runtime/package.json +1 -1
  96. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  97. data/vendor/javascript/vite-plugin-ruact/index.js +372 -6
  98. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  99. data/vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs +125 -0
  100. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  101. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  102. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  103. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  104. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  105. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  106. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  107. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  108. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  109. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  110. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  118. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  119. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  120. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  121. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  122. metadata +58 -15
  123. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  124. data/lib/ruact/server_action.rb +0 -131
  125. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  126. data/lib/ruact/server_functions/registry.rb +0 -148
  127. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  128. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  129. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  130. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  131. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  132. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  133. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  134. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  135. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
@@ -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,51 @@ 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
+
175
+ // Serve the LIVE in-memory manifest over HTTP so the Rails gem can
176
+ // resolve components in dev without depending on the on-disk
177
+ // public/react-client-manifest.json whose first write races Rails's
178
+ // boot-time read (Rails can boot, and config.to_prepare read the file,
179
+ // BEFORE Vite writes it → Ruact.manifest nil → 500 on the first request).
180
+ // This endpoint always reflects the current `manifest` (rebuilt by the
181
+ // watcher below + buildStart), and omits the internal `_sourceFile` field
182
+ // so the wire shape matches the file the gem already knows how to parse.
183
+ server.middlewares.use("/__ruact/manifest", (req, res, next) => {
184
+ if (req.method !== "GET") return next();
185
+ const out = {};
186
+ for (const [name, entry] of Object.entries(manifest)) {
187
+ const { _sourceFile, ...rest } = entry;
188
+ out[name] = rest;
84
189
  }
190
+ res.setHeader("Content-Type", "application/json");
191
+ res.end(JSON.stringify(out));
85
192
  });
193
+
194
+ const rebuild = (file) => {
195
+ if (!file.startsWith(dir)) return;
196
+ manifest = buildManifest(dir);
197
+ writeManifest(path.resolve(root, manifestOutput), manifest);
198
+ const mod = server.moduleGraph.getModuleById(RESOLVED_REGISTRY_ID);
199
+ if (mod) {
200
+ server.moduleGraph.invalidateModule(mod);
201
+ server.ws.send({ type: "full-reload" });
202
+ }
203
+ };
204
+ server.watcher.on("change", rebuild);
205
+ server.watcher.on("add", rebuild);
206
+ server.watcher.on("unlink", rebuild);
86
207
  },
87
208
  }, options);
88
209
  }
89
210
 
90
- function buildManifest(componentsDir) {
211
+ export function buildManifest(componentsDir) {
91
212
  const manifest = {};
92
213
 
93
214
  if (!fs.existsSync(componentsDir)) return manifest;
@@ -102,6 +223,22 @@ function buildManifest(componentsDir) {
102
223
 
103
224
  const exports = extractExportNames(content);
104
225
  const relUrl = "/" + path.relative(componentsDir, file);
226
+ let contract = extractContract(content, file);
227
+
228
+ // Story 13.5 — a single `__ruactContract` cannot say WHICH component it
229
+ // describes, so it only applies when the file has exactly one component
230
+ // export (the documented one-component-per-file convention). A multi-export
231
+ // file would otherwise validate every export against the same contract —
232
+ // warn + skip rather than guess.
233
+ if (contract && exports.length > 1) {
234
+ // eslint-disable-next-line no-console
235
+ console.warn(
236
+ `[vite-plugin-ruact] ignoring __ruactContract in ${file}: a contract ` +
237
+ `applies to a single component, but this file exports ${exports.length} ` +
238
+ `(${exports.join(", ")}). Split them into one component per file.`
239
+ );
240
+ contract = null;
241
+ }
105
242
 
106
243
  for (const name of exports) {
107
244
  manifest[name] = {
@@ -110,12 +247,93 @@ function buildManifest(componentsDir) {
110
247
  chunks: [relUrl],
111
248
  _sourceFile: file, // used during build to match hashed chunks
112
249
  };
250
+ // Story 13.5 — opt-in, byte-additive: only present when declared.
251
+ if (contract) manifest[name].contract = contract;
113
252
  }
114
253
  }
115
254
 
116
255
  return manifest;
117
256
  }
118
257
 
258
+ // Story 10.1b (AC1, AC2) — render the auto-registry virtual module source from a
259
+ // manifest (the same object `buildManifest` produces). The result is an ESM
260
+ // module whose default export is `{ [manifest id]: moduleNamespace }`:
261
+ //
262
+ // import * as __ruact_m0 from "/abs/app/javascript/components/PostList.jsx";
263
+ // const MODULE_REGISTRY = { "/PostList.jsx": __ruact_m0 };
264
+ // export default MODULE_REGISTRY;
265
+ //
266
+ // Why keyed on the manifest `id` (not a re-derived path): the gem serializes
267
+ // `entry["id"]` as the Flight `moduleId`, and the client resolves
268
+ // `moduleRegistry[row.moduleId]` — so keying the registry on the very same
269
+ // `id` makes the id-match invariant hold BY CONSTRUCTION. The shipped runtime
270
+ // loads components from this eager registry (the Flight client ignores the
271
+ // Import row's `chunks`), so each component is statically imported and inlined
272
+ // into the app bundle; it never becomes a standalone facade chunk, and
273
+ // `generateBundle`'s hashed-URL rewrite (which only fires for facade chunks)
274
+ // leaves the component `id` as its source-relative path in prod exactly as in
275
+ // dev. NFR16 dev/prod parity therefore holds with one source-relative key set.
276
+ //
277
+ // One import per source file (a file may export several components sharing an
278
+ // `id`/`_sourceFile`); membership equals manifest membership because we iterate
279
+ // the manifest the scan built ("use client"-only, `.jsx`/`.tsx`, same dir).
280
+ export function generateRegistrySource(manifest) {
281
+ const byId = new Map(); // manifest id -> absolute source file
282
+ for (const entry of Object.values(manifest || {})) {
283
+ if (!entry || !entry._sourceFile || !entry.id) continue;
284
+ if (!byId.has(entry.id)) byId.set(entry.id, entry._sourceFile);
285
+ }
286
+
287
+ const lines = [
288
+ "// AUTO-GENERATED by vite-plugin-ruact — component auto-registry (Story 10.1b).",
289
+ "// Maps each react-client-manifest `id` to its module exports. Do not edit.",
290
+ ];
291
+ const props = [];
292
+ let i = 0;
293
+ for (const [id, sourceFile] of byId) {
294
+ const local = `__ruact_m${i++}`;
295
+ lines.push(`import * as ${local} from ${JSON.stringify(toImportSpecifier(sourceFile))};`);
296
+ props.push(` ${JSON.stringify(id)}: ${local},`);
297
+ }
298
+ lines.push("const MODULE_REGISTRY = {", ...props, "};", "export default MODULE_REGISTRY;", "");
299
+
300
+ return lines.join("\n");
301
+ }
302
+
303
+ // Story 14.2 (FR104) — render the virtual bootstrap source from the gem-shipped
304
+ // `runtime/bootstrap.jsx`. A `load` hook returns module TEXT whose relative
305
+ // imports resolve against the resolved id (`\0virtual:ruact/bootstrap`), which
306
+ // is NOT a filesystem path — so `./flight-client.js` / `./ruact-router.js`
307
+ // would fail. We rewrite those two specifiers to ABSOLUTE fs specifiers into the
308
+ // gem `runtime/` dir (the same technique `generateRegistrySource` uses via
309
+ // `toImportSpecifier`). The bare `react` / `react-dom/client` specifiers and the
310
+ // `virtual:ruact/registry` id are left untouched — Vite resolves them from the
311
+ // app root (so React comes from the USER's node_modules: one React instance).
312
+ export function generateBootstrapSource(runtimeDir = RUNTIME_DIR) {
313
+ const src = fs.readFileSync(path.join(runtimeDir, "bootstrap.jsx"), "utf8");
314
+ const abs = (name) => toImportSpecifier(path.join(runtimeDir, name));
315
+ // Rewrite EVERY `from './<runtime>.js'` import specifier to its absolute fs
316
+ // path. Matching the quoted `from '...'` form (not the bare filename) avoids
317
+ // hitting prose mentions of the file in comments, and the global regex
318
+ // tolerates either quote style. The replacement is supplied as a function so a
319
+ // `$` in the absolute path is never treated as a replacement pattern.
320
+ const rewrite = (code, name) => {
321
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
322
+ return code.replace(
323
+ new RegExp(`from\\s+(['"])\\./${escaped}\\1`, "g"),
324
+ () => `from '${abs(name)}'`,
325
+ );
326
+ };
327
+ return rewrite(rewrite(src, "flight-client.js"), "ruact-router.js");
328
+ }
329
+
330
+ // A bundler import specifier for an absolute fs path. Vite/Rollup resolve
331
+ // absolute paths directly; normalize Windows backslashes to forward slashes so
332
+ // the emitted specifier is a valid module string on every platform.
333
+ function toImportSpecifier(absPath) {
334
+ return absPath.replace(/\\/g, "/");
335
+ }
336
+
119
337
  function hasUseClient(content) {
120
338
  // "use client" must appear as a directive at the top of the file
121
339
  return /^\s*["']use client["']/m.test(content);
@@ -145,6 +363,154 @@ function extractExportNames(content) {
145
363
  return Array.from(names);
146
364
  }
147
365
 
366
+ // Story 13.5 (FR100) — extract the opt-in `__ruactContract` declaration.
367
+ //
368
+ // NAMES-ONLY by design (no TS-AST, no value types): the Ruby preprocess-time
369
+ // validator only needs prop/slot names + required/optional. We locate the
370
+ // exported object literal, balance its braces, then pull names out of the
371
+ // `props` / `slots` sub-blocks with targeted regexes (robust to formatting,
372
+ // and immune to eval). Returns:
373
+ // - null when the component declares no contract (byte-additive opt-out)
374
+ // - null when the declaration is malformed/empty (warn + skip → Ruby fails open)
375
+ // - { props?, slots?, passthrough? } otherwise (only non-empty members present)
376
+ export function extractContract(content, file) {
377
+ const marker = /export\s+(?:default\s+)?(?:const|let|var)\s+__ruactContract\s*=\s*/m;
378
+ const m = marker.exec(content);
379
+ if (!m) return null;
380
+
381
+ const braceStart = content.indexOf("{", m.index + m[0].length);
382
+ if (braceStart === -1) return warnSkip(file);
383
+
384
+ const objText = extractBalanced(content, braceStart, "{", "}");
385
+ if (objText === null) return warnSkip(file);
386
+
387
+ const contract = {};
388
+
389
+ const props = extractRequiredness(extractBlock(objText, "props", "{", "}"));
390
+ if (props && Object.keys(props).length) contract.props = props;
391
+
392
+ const slots = extractRequiredness(extractBlock(objText, "slots", "{", "}"));
393
+ if (slots && Object.keys(slots).length) {
394
+ contract.slots = slots;
395
+ } else {
396
+ // Array form: slots: ["header", "footer"] → all optional.
397
+ const arr = extractBlock(objText, "slots", "[", "]");
398
+ if (arr !== null) {
399
+ const names = {};
400
+ const nameRe = /["'`]([^"'`]+)["'`]/g;
401
+ let s;
402
+ while ((s = nameRe.exec(arr)) !== null) names[s[1]] = "optional";
403
+ if (Object.keys(names).length) contract.slots = names;
404
+ }
405
+ }
406
+
407
+ if (/\bpassthrough\s*:\s*true\b/.test(objText)) contract.passthrough = true;
408
+
409
+ // A contract with no props, no slots, and no passthrough carries no
410
+ // information — treat as malformed/empty and fail open.
411
+ if (!contract.props && !contract.slots && !contract.passthrough) {
412
+ return warnSkip(file);
413
+ }
414
+
415
+ return contract;
416
+ }
417
+
418
+ // Parse a `{ name: "required", other: "optional" }` block into a
419
+ // { name: "required" | "optional" } map. Accepts quoted or bare values, and an
420
+ // object form `name: { required: true }`. Returns {} for an empty block.
421
+ function extractRequiredness(block) {
422
+ if (block === null) return null;
423
+ const map = {};
424
+ // name: "required" | 'optional' | required (string or bare identifier)
425
+ const strRe = /([A-Za-z_$][\w$]*)\s*:\s*["'`]?(required|optional)["'`]?/g;
426
+ let m;
427
+ while ((m = strRe.exec(block)) !== null) map[m[1]] = m[2];
428
+ // name: { required: true } → required; { required: false } → optional
429
+ const objRe = /([A-Za-z_$][\w$]*)\s*:\s*\{[^}]*?\brequired\s*:\s*(true|false)[^}]*?\}/g;
430
+ while ((m = objRe.exec(block)) !== null) {
431
+ map[m[1]] = m[2] === "true" ? "required" : "optional";
432
+ }
433
+ return map;
434
+ }
435
+
436
+ // Extract the balanced inner text of `key: <open> ... <close>` from `objText`.
437
+ // Returns the inner text (without the delimiters) or null when the key is
438
+ // absent or the delimiters are unbalanced.
439
+ function extractBlock(objText, key, open, close) {
440
+ const keyRe = new RegExp(`\\b${key}\\s*:\\s*\\${open}`, "m");
441
+ const km = keyRe.exec(objText);
442
+ if (!km) return null;
443
+ const start = km.index + km[0].length - 1; // position of `open`
444
+ const inner = extractBalanced(objText, start, open, close);
445
+ return inner;
446
+ }
447
+
448
+ // Given a string and the index of an opening delimiter, return the inner text
449
+ // up to (excluding) the matching close delimiter, or null when unbalanced.
450
+ // String literals ('...', "...", `...`) and comments (// and / * ... * /) are
451
+ // skipped so a brace inside a comment or string never throws off the balance.
452
+ function extractBalanced(str, openIndex, open, close) {
453
+ let depth = 0;
454
+ let i = openIndex;
455
+ while (i < str.length) {
456
+ const ch = str[i];
457
+ const next = str[i + 1];
458
+
459
+ if (ch === "/" && next === "/") {
460
+ const nl = str.indexOf("\n", i);
461
+ if (nl === -1) return null;
462
+ i = nl + 1;
463
+ continue;
464
+ }
465
+ if (ch === "/" && next === "*") {
466
+ const end = str.indexOf("*/", i + 2);
467
+ if (end === -1) return null;
468
+ i = end + 2;
469
+ continue;
470
+ }
471
+ if (ch === '"' || ch === "'" || ch === "`") {
472
+ i = skipString(str, i);
473
+ if (i === -1) return null;
474
+ continue;
475
+ }
476
+
477
+ if (ch === open) {
478
+ depth++;
479
+ } else if (ch === close) {
480
+ depth--;
481
+ if (depth === 0) return str.slice(openIndex + 1, i);
482
+ }
483
+ i++;
484
+ }
485
+ return null;
486
+ }
487
+
488
+ // Skip a JS string/template literal starting at the opening quote at +i+.
489
+ // Returns the index just past the closing quote, or -1 if unterminated.
490
+ function skipString(str, i) {
491
+ const quote = str[i];
492
+ i++;
493
+ while (i < str.length) {
494
+ const ch = str[i];
495
+ if (ch === "\\") {
496
+ i += 2;
497
+ continue;
498
+ }
499
+ if (ch === quote) return i + 1;
500
+ i++;
501
+ }
502
+ return -1;
503
+ }
504
+
505
+ function warnSkip(file) {
506
+ // eslint-disable-next-line no-console
507
+ console.warn(
508
+ `[vite-plugin-ruact] ignoring malformed __ruactContract in ${file} ` +
509
+ `(could not extract prop/slot names — the component will not be contract-validated)`
510
+ );
511
+ return null;
512
+ }
513
+
148
514
  function writeManifest(outputPath, manifest) {
149
515
  fs.mkdirSync(path.dirname(outputPath), { recursive: true });
150
516
  fs.writeFileSync(outputPath, JSON.stringify(manifest, null, 2));
@@ -0,0 +1,182 @@
1
+ // Story 13.5 (FR100) — buildManifest contract extraction.
2
+ //
3
+ // A component opts into a call-site contract by exporting `__ruactContract`.
4
+ // The scanner extracts it NAMES-ONLY into an optional `contract` field on the
5
+ // manifest entry. A component without the export emits NO `contract` field
6
+ // (byte-additive). A malformed declaration is warned + skipped (fail open).
7
+
8
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
9
+ import fs from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { buildManifest, extractContract } from "./index.js";
13
+
14
+ let dir;
15
+ beforeEach(() => {
16
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "ruact-13-5-"));
17
+ });
18
+ afterEach(() => {
19
+ fs.rmSync(dir, { recursive: true, force: true });
20
+ });
21
+
22
+ function writeComponent(name, body) {
23
+ fs.writeFileSync(path.join(dir, name), body);
24
+ }
25
+
26
+ describe("buildManifest — contract extraction (Story 13.5)", () => {
27
+ it("attaches a `contract` field when the component exports __ruactContract", () => {
28
+ writeComponent(
29
+ "LikeButton.tsx",
30
+ [
31
+ '"use client";',
32
+ "export const __ruactContract = {",
33
+ ' props: { postId: "required", initialCount: "optional" },',
34
+ "};",
35
+ "export function LikeButton() { return null; }",
36
+ ].join("\n")
37
+ );
38
+
39
+ const manifest = buildManifest(dir);
40
+
41
+ expect(manifest.LikeButton.contract).toEqual({
42
+ props: { postId: "required", initialCount: "optional" },
43
+ });
44
+ // The base entry shape is untouched.
45
+ expect(manifest.LikeButton.id).toBe("/LikeButton.tsx");
46
+ expect(manifest.LikeButton.name).toBe("LikeButton");
47
+ });
48
+
49
+ it("emits NO `contract` field when the component declares none (byte-additive)", () => {
50
+ writeComponent(
51
+ "Plain.tsx",
52
+ ['"use client";', "export function Plain() { return null; }"].join("\n")
53
+ );
54
+
55
+ const manifest = buildManifest(dir);
56
+
57
+ expect(manifest.Plain).toBeDefined();
58
+ expect("contract" in manifest.Plain).toBe(false);
59
+ });
60
+
61
+ it("extracts slots (object form) and passthrough", () => {
62
+ writeComponent(
63
+ "Card.tsx",
64
+ [
65
+ '"use client";',
66
+ "export const __ruactContract = {",
67
+ ' props: { title: "required" },',
68
+ ' slots: { header: "optional", footer: "required" },',
69
+ " passthrough: true,",
70
+ "};",
71
+ "export const Card = () => null;",
72
+ ].join("\n")
73
+ );
74
+
75
+ const manifest = buildManifest(dir);
76
+
77
+ expect(manifest.Card.contract).toEqual({
78
+ props: { title: "required" },
79
+ slots: { header: "optional", footer: "required" },
80
+ passthrough: true,
81
+ });
82
+ });
83
+
84
+ it("extracts slots in array form (all optional)", () => {
85
+ const c = extractContract(
86
+ [
87
+ "export const __ruactContract = {",
88
+ ' props: { title: "required" },',
89
+ ' slots: ["header", "footer"],',
90
+ "};",
91
+ ].join("\n"),
92
+ "Card.tsx"
93
+ );
94
+ expect(c).toEqual({
95
+ props: { title: "required" },
96
+ slots: { header: "optional", footer: "optional" },
97
+ });
98
+ });
99
+
100
+ it("handles nested braces in prop values without breaking balance", () => {
101
+ const c = extractContract(
102
+ [
103
+ "export const __ruactContract = {",
104
+ " props: { a: { required: true }, b: { required: false } },",
105
+ "};",
106
+ ].join("\n"),
107
+ "Nested.tsx"
108
+ );
109
+ expect(c).toEqual({ props: { a: "required", b: "optional" } });
110
+ });
111
+
112
+ it("ignores braces inside comments and strings when balancing", () => {
113
+ const c = extractContract(
114
+ [
115
+ "export const __ruactContract = {",
116
+ " // a stray brace in a comment { should not break balance",
117
+ ' props: { label: "required" }, // trailing }',
118
+ ' slots: ["a-}-slot"],',
119
+ "};",
120
+ "export const X = () => null;",
121
+ ].join("\n"),
122
+ "Commented.tsx"
123
+ );
124
+ expect(c).toEqual({
125
+ props: { label: "required" },
126
+ slots: { "a-}-slot": "optional" },
127
+ });
128
+ });
129
+
130
+ it("skips the contract for a multi-component file (warn) — cannot attribute it", () => {
131
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
132
+ writeComponent(
133
+ "Pair.tsx",
134
+ [
135
+ '"use client";',
136
+ 'export const __ruactContract = { props: { x: "required" } };',
137
+ "export function Primary() { return null; }",
138
+ "export function Secondary() { return null; }",
139
+ ].join("\n")
140
+ );
141
+ const manifest = buildManifest(dir);
142
+ expect("contract" in manifest.Primary).toBe(false);
143
+ expect("contract" in manifest.Secondary).toBe(false);
144
+ expect(warn).toHaveBeenCalledOnce();
145
+ warn.mockRestore();
146
+ });
147
+
148
+ it("warns and skips a malformed declaration (unbalanced braces) → null", () => {
149
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
150
+ const c = extractContract(
151
+ "export const __ruactContract = { props: { a: 'required' ",
152
+ "Broken.tsx"
153
+ );
154
+ expect(c).toBeNull();
155
+ expect(warn).toHaveBeenCalledOnce();
156
+ warn.mockRestore();
157
+ });
158
+
159
+ it("warns and skips an information-less declaration → null", () => {
160
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
161
+ const c = extractContract("export const __ruactContract = {};", "Empty.tsx");
162
+ expect(c).toBeNull();
163
+ warn.mockRestore();
164
+ });
165
+
166
+ it("returns null when there is no __ruactContract export", () => {
167
+ expect(extractContract("export const Foo = 1;", "None.tsx")).toBeNull();
168
+ });
169
+
170
+ it("does not treat __ruactContract itself as a component export", () => {
171
+ writeComponent(
172
+ "Widget.tsx",
173
+ [
174
+ '"use client";',
175
+ 'export const __ruactContract = { props: { x: "required" } };',
176
+ "export function Widget() { return null; }",
177
+ ].join("\n")
178
+ );
179
+ const manifest = buildManifest(dir);
180
+ expect(Object.keys(manifest)).toEqual(["Widget"]);
181
+ });
182
+ });