ruact 0.0.2 → 0.0.4

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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/.codecov.yml +31 -0
  3. data/.github/workflows/ci.yml +160 -94
  4. data/.github/workflows/server-functions-bench.yml +54 -0
  5. data/.rubocop.yml +19 -1
  6. data/.rubocop_todo.yml +175 -0
  7. data/CHANGELOG.md +88 -5
  8. data/README.md +2 -0
  9. data/RELEASING.md +9 -3
  10. data/bench/server_functions_dispatch_bench.rb +309 -0
  11. data/bench/server_functions_dispatch_bench.results.md +121 -0
  12. data/docs/internal/README.md +9 -0
  13. data/docs/internal/decisions/server-functions-api.md +1779 -0
  14. data/lib/generators/ruact/install/install_generator.rb +43 -0
  15. data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
  16. data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
  17. data/lib/ruact/client_manifest.rb +125 -12
  18. data/lib/ruact/configuration.rb +264 -23
  19. data/lib/ruact/controller.rb +459 -32
  20. data/lib/ruact/doctor.rb +34 -2
  21. data/lib/ruact/erb_preprocessor.rb +6 -6
  22. data/lib/ruact/errors.rb +100 -0
  23. data/lib/ruact/flight/serializer.rb +2 -2
  24. data/lib/ruact/html_converter.rb +131 -31
  25. data/lib/ruact/query.rb +107 -0
  26. data/lib/ruact/railtie.rb +220 -3
  27. data/lib/ruact/render_context.rb +30 -0
  28. data/lib/ruact/render_pipeline.rb +201 -59
  29. data/lib/ruact/routing.rb +81 -0
  30. data/lib/ruact/serializable.rb +11 -11
  31. data/lib/ruact/server.rb +341 -0
  32. data/lib/ruact/server_action.rb +131 -0
  33. data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
  34. data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
  35. data/lib/ruact/server_functions/codegen.rb +344 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +212 -0
  37. data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
  38. data/lib/ruact/server_functions/error_payload.rb +93 -0
  39. data/lib/ruact/server_functions/error_rendering.rb +190 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +118 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +313 -0
  44. data/lib/ruact/server_functions/query_source.rb +150 -0
  45. data/lib/ruact/server_functions/registry.rb +148 -0
  46. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  47. data/lib/ruact/server_functions/route_source.rb +201 -0
  48. data/lib/ruact/server_functions/snapshot.rb +195 -0
  49. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  50. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  51. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  52. data/lib/ruact/server_functions.rb +111 -0
  53. data/lib/ruact/version.rb +1 -1
  54. data/lib/ruact/view_helper.rb +17 -9
  55. data/lib/ruact.rb +85 -6
  56. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  57. data/lib/tasks/benchmark.rake +15 -11
  58. data/lib/tasks/ruact.rake +81 -0
  59. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  60. data/spec/fixtures/flight/README.md +55 -7
  61. data/spec/fixtures/flight/bigint.txt +1 -0
  62. data/spec/fixtures/flight/infinity.txt +1 -0
  63. data/spec/fixtures/flight/nan.txt +1 -0
  64. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  65. data/spec/fixtures/flight/undefined.txt +1 -0
  66. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  67. data/spec/ruact/client_manifest_spec.rb +108 -0
  68. data/spec/ruact/configuration_spec.rb +501 -0
  69. data/spec/ruact/controller_request_spec.rb +204 -0
  70. data/spec/ruact/controller_spec.rb +427 -39
  71. data/spec/ruact/doctor_spec.rb +118 -0
  72. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  73. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  74. data/spec/ruact/errors_spec.rb +95 -0
  75. data/spec/ruact/flight/renderer_spec.rb +14 -3
  76. data/spec/ruact/flight/serializer_spec.rb +129 -88
  77. data/spec/ruact/html_converter_spec.rb +183 -5
  78. data/spec/ruact/install_generator_spec.rb +93 -0
  79. data/spec/ruact/query_request_spec.rb +598 -0
  80. data/spec/ruact/query_spec.rb +105 -0
  81. data/spec/ruact/railtie_spec.rb +2 -3
  82. data/spec/ruact/render_context_spec.rb +58 -0
  83. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  84. data/spec/ruact/render_pipeline_spec.rb +784 -330
  85. data/spec/ruact/serializable_spec.rb +8 -8
  86. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  87. data/spec/ruact/server_function_name_spec.rb +53 -0
  88. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  89. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  90. data/spec/ruact/server_functions/codegen_spec.rb +508 -0
  91. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  92. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  93. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  94. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  95. data/spec/ruact/server_functions/name_bridge_spec.rb +212 -0
  96. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  97. data/spec/ruact/server_functions/query_source_spec.rb +142 -0
  98. data/spec/ruact/server_functions/railtie_integration_spec.rb +412 -0
  99. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  100. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  101. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  102. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  103. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  104. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  105. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  106. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  107. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  108. data/spec/ruact/server_spec.rb +180 -0
  109. data/spec/ruact/server_upload_request_spec.rb +311 -0
  110. data/spec/ruact/view_helper_spec.rb +23 -17
  111. data/spec/spec_helper.rb +52 -1
  112. data/spec/support/fixtures/pixel.png +0 -0
  113. data/spec/support/flight_wire_parser.rb +135 -0
  114. data/spec/support/flight_wire_parser_spec.rb +93 -0
  115. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  116. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  117. data/spec/support/rails_stub.rb +75 -5
  118. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +173 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/index.js +614 -0
  120. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  121. data/vendor/javascript/ruact-server-functions-runtime/package.json +29 -0
  122. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +181 -0
  123. data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
  124. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  125. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  126. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +804 -0
  127. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +961 -0
  128. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +21 -0
  129. metadata +91 -5
  130. data/lib/ruact/component_registry.rb +0 -31
  131. data/lib/tasks/rsc.rake +0 -9
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "ruact-server-functions-runtime",
3
+ "version": "0.3.0",
4
+ "description": "Server-functions runtime for ruact gem (Stories 8.1 + 8.2 + 9.5). Provides `_makeRef(name)` / `_makeServerFunction(descriptor)` (mutations: POST/PUT/PATCH/DELETE with CSRF + JSON / FormData), `revalidate(path?)` (Flight refetch), and `_makeQuery(descriptor)` + `useQuery(ref, params?)` (reads: GET /q/<jsId>, CSRF-free, FR88 primitive params → { data, loading, error }).",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "types": "./index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "default": "./index.js"
12
+ }
13
+ },
14
+ "license": "MIT",
15
+ "private": true,
16
+ "scripts": {
17
+ "test": "vitest run"
18
+ },
19
+ "peerDependencies": {
20
+ "react": ">=18"
21
+ },
22
+ "devDependencies": {
23
+ "@testing-library/react": "^16.1.0",
24
+ "jsdom": "^25.0.1",
25
+ "react": "^19.0.0",
26
+ "react-dom": "^19.0.0",
27
+ "vitest": "^2.1.9"
28
+ }
29
+ }
@@ -0,0 +1,181 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // Story 9.5 — vitest coverage for the read-side runtime: the `useQuery` React
4
+ // hook (loading/data/error transitions, param passing, value-stable refetch)
5
+ // and the `_makeQuery` GET wire format (query-string encoding, FR88 primitive
6
+ // allowlist, CSRF-free init). Runs under jsdom so `@testing-library/react`'s
7
+ // `renderHook` can drive the hook through React's effect lifecycle.
8
+
9
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
10
+ import { renderHook, waitFor } from "@testing-library/react";
11
+
12
+ import { useQuery, _makeQuery, RuactActionError, __internals } from "./index.js";
13
+
14
+ let originalFetch;
15
+
16
+ beforeEach(() => {
17
+ originalFetch = globalThis.fetch;
18
+ });
19
+
20
+ afterEach(() => {
21
+ globalThis.fetch = originalFetch;
22
+ vi.restoreAllMocks();
23
+ });
24
+
25
+ function mockFetchOk(jsonBody, { status = 200, contentType = "application/json" } = {}) {
26
+ const response = {
27
+ ok: true,
28
+ status,
29
+ headers: { get: (n) => (n.toLowerCase() === "content-type" ? contentType : null) },
30
+ text: vi.fn().mockResolvedValue(typeof jsonBody === "string" ? jsonBody : JSON.stringify(jsonBody)),
31
+ };
32
+ globalThis.fetch = vi.fn().mockResolvedValue(response);
33
+ return response;
34
+ }
35
+
36
+ function mockFetchError(status, bodyText) {
37
+ const response = {
38
+ ok: false,
39
+ status,
40
+ headers: { get: () => "text/plain" },
41
+ text: vi.fn().mockResolvedValue(bodyText),
42
+ };
43
+ globalThis.fetch = vi.fn().mockResolvedValue(response);
44
+ return response;
45
+ }
46
+
47
+ describe("Story 9.5 — useQuery hook contract", () => {
48
+ it("starts loading, then resolves to { data, loading: false, error: null }", async () => {
49
+ const ref = vi.fn().mockResolvedValue({ items: [1, 2] });
50
+ const { result } = renderHook(() => useQuery(ref));
51
+
52
+ expect(result.current.loading).toBe(true);
53
+ expect(result.current.data).toBeUndefined();
54
+
55
+ await waitFor(() => expect(result.current.loading).toBe(false));
56
+ expect(result.current.data).toEqual({ items: [1, 2] });
57
+ expect(result.current.error).toBe(null);
58
+ });
59
+
60
+ it("transitions loading → error on a rejected reference", async () => {
61
+ const err = new RuactActionError({ name: "/q/categories", status: 500, body: "boom", response: {} });
62
+ const ref = vi.fn().mockRejectedValue(err);
63
+ const { result } = renderHook(() => useQuery(ref));
64
+
65
+ await waitFor(() => expect(result.current.loading).toBe(false));
66
+ expect(result.current.error).toBe(err);
67
+ expect(result.current.data).toBeUndefined();
68
+ });
69
+
70
+ it("passes params to the query reference", async () => {
71
+ const ref = vi.fn().mockResolvedValue("ok");
72
+ const params = { q: "ruby", limit: 5 };
73
+ renderHook(() => useQuery(ref, params));
74
+
75
+ await waitFor(() => expect(ref).toHaveBeenCalledTimes(1));
76
+ expect(ref).toHaveBeenCalledWith(params);
77
+ });
78
+
79
+ it("does not refetch when params are value-equal across renders", async () => {
80
+ const ref = vi.fn().mockResolvedValue("ok");
81
+ const { rerender } = renderHook(({ p }) => useQuery(ref, p), {
82
+ initialProps: { p: { q: "a" } },
83
+ });
84
+ await waitFor(() => expect(ref).toHaveBeenCalledTimes(1));
85
+
86
+ rerender({ p: { q: "a" } }); // fresh object literal, identical value
87
+ await Promise.resolve();
88
+ expect(ref).toHaveBeenCalledTimes(1);
89
+ });
90
+
91
+ it("refetches when params change by value", async () => {
92
+ const ref = vi.fn().mockResolvedValue("ok");
93
+ const { rerender } = renderHook(({ p }) => useQuery(ref, p), {
94
+ initialProps: { p: { q: "a" } },
95
+ });
96
+ await waitFor(() => expect(ref).toHaveBeenCalledTimes(1));
97
+
98
+ rerender({ p: { q: "b" } });
99
+ await waitFor(() => expect(ref).toHaveBeenCalledTimes(2));
100
+ expect(ref).toHaveBeenLastCalledWith({ q: "b" });
101
+ });
102
+
103
+ it("surfaces a synchronous throw from the reference as an error (not an unhandled rejection)", async () => {
104
+ const ref = () => {
105
+ throw new TypeError("params must be a plain object");
106
+ };
107
+ const { result } = renderHook(() => useQuery(ref, [1, 2]));
108
+ await waitFor(() => expect(result.current.loading).toBe(false));
109
+ expect(result.current.error).toBeInstanceOf(TypeError);
110
+ });
111
+ });
112
+
113
+ describe("Story 9.5 — useQuery against _makeQuery end-to-end (GET wire)", () => {
114
+ it("issues GET /q/<id>?<params> and resolves the JSON body through the hook", async () => {
115
+ mockFetchOk([{ value: 1, label: "Books" }]);
116
+ const categories = _makeQuery({ path: "/q/categories", kind: "query" });
117
+
118
+ const { result } = renderHook(() => useQuery(categories, { q: "bo" }));
119
+ await waitFor(() => expect(result.current.loading).toBe(false));
120
+
121
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
122
+ const [url, init] = globalThis.fetch.mock.calls[0];
123
+ expect(url).toBe("/q/categories?q=bo");
124
+ expect(init.method).toBe("GET");
125
+ expect(result.current.data).toEqual([{ value: 1, label: "Books" }]);
126
+ });
127
+
128
+ it("carries the structured RuactActionError into the hook's error on a 4xx", async () => {
129
+ mockFetchError(400, "bad");
130
+ const search = _makeQuery({ path: "/q/search", kind: "query" });
131
+
132
+ const { result } = renderHook(() => useQuery(search));
133
+ await waitFor(() => expect(result.current.loading).toBe(false));
134
+
135
+ expect(result.current.error).toBeInstanceOf(RuactActionError);
136
+ expect(result.current.error.status).toBe(400);
137
+ });
138
+ });
139
+
140
+ describe("Story 9.5 — _makeQuery / buildQueryUrl wire format (FR88)", () => {
141
+ const { buildQueryUrl, buildQueryFetchInit } = __internals;
142
+
143
+ it("omits the query string entirely when no params are given", () => {
144
+ expect(buildQueryUrl("/q/categories", undefined)).toBe("/q/categories");
145
+ expect(buildQueryUrl("/q/categories", null)).toBe("/q/categories");
146
+ expect(buildQueryUrl("/q/categories", {})).toBe("/q/categories");
147
+ });
148
+
149
+ it("encodes string / number / boolean primitives", () => {
150
+ expect(buildQueryUrl("/q/search", { q: "a b", limit: 5, active: true })).toBe(
151
+ "/q/search?q=a+b&limit=5&active=true",
152
+ );
153
+ });
154
+
155
+ it("encodes null as a BARE key (Rack parses `?q` as nil, distinct from `?q=` empty string)", () => {
156
+ expect(buildQueryUrl("/q/search", { q: null })).toBe("/q/search?q");
157
+ // value-bearing params keep `key=value`; a null alongside is a bare key
158
+ expect(buildQueryUrl("/q/search", { limit: 5, q: null })).toBe("/q/search?limit=5&q");
159
+ });
160
+
161
+ it("rejects an array value (FR88 — arrays are not primitives)", () => {
162
+ expect(() => buildQueryUrl("/q/search", { q: [1, 2] })).toThrow(/arrays and objects are rejected/);
163
+ });
164
+
165
+ it("rejects an object value", () => {
166
+ expect(() => buildQueryUrl("/q/search", { q: { deep: 1 } })).toThrow(/arrays and objects are rejected/);
167
+ });
168
+
169
+ it("rejects a top-level array of params", () => {
170
+ expect(() => buildQueryUrl("/q/search", [1, 2])).toThrow(/plain object/);
171
+ });
172
+
173
+ it("builds a GET init with Accept JSON, no body, no CSRF, redirect: error", () => {
174
+ const init = buildQueryFetchInit();
175
+ expect(init.method).toBe("GET");
176
+ expect(init.body).toBeUndefined();
177
+ expect(init.headers.Accept).toBe("application/json");
178
+ expect(init.headers["X-CSRF-Token"]).toBeUndefined();
179
+ expect(init.redirect).toBe("error");
180
+ });
181
+ });
@@ -0,0 +1,164 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { installServerFunctionsHooks } from "./server-functions-codegen.mjs";
4
+
5
+ /**
6
+ * vite-plugin-ruact
7
+ *
8
+ * Scans app/javascript/components for files with "use client" directives and
9
+ * emits public/react-client-manifest.json so the Rails gem can resolve
10
+ * component names to chunk URLs.
11
+ *
12
+ * Manifest format:
13
+ * {
14
+ * "LikeButton": {
15
+ * "id": "/assets/LikeButton-abc123.js",
16
+ * "name": "LikeButton",
17
+ * "chunks": ["/assets/LikeButton-abc123.js"]
18
+ * }
19
+ * }
20
+ */
21
+ export default function ruact(options = {}) {
22
+ const {
23
+ componentsDir = "app/javascript/components",
24
+ manifestOutput = "public/react-client-manifest.json",
25
+ } = options;
26
+
27
+ let root;
28
+ let manifest = {};
29
+
30
+ return installServerFunctionsHooks({
31
+ name: "vite-plugin-ruact",
32
+
33
+ configResolved(config) {
34
+ root = config.root;
35
+ },
36
+
37
+ // During dev: build the manifest from source files
38
+ buildStart() {
39
+ manifest = buildManifest(path.resolve(root, componentsDir));
40
+ writeManifest(path.resolve(root, manifestOutput), manifest);
41
+ },
42
+
43
+ // During build: update with hashed chunk URLs from the bundle
44
+ generateBundle(_options, bundle) {
45
+ const updated = {};
46
+
47
+ for (const [chunkFileName, chunk] of Object.entries(bundle)) {
48
+ if (chunk.type !== "chunk") continue;
49
+
50
+ const facadeId = chunk.facadeModuleId;
51
+ if (!facadeId) continue;
52
+
53
+ // Find manifest entries whose source file matches this chunk
54
+ for (const [name, entry] of Object.entries(manifest)) {
55
+ if (facadeId === entry._sourceFile) {
56
+ const url = "/" + chunkFileName;
57
+ updated[name] = {
58
+ id: url,
59
+ name,
60
+ chunks: [url],
61
+ };
62
+ }
63
+ }
64
+ }
65
+
66
+ // Merge: keep entries that didn't get a hashed URL (dev mode)
67
+ const final = { ...manifest, ...updated };
68
+ // Strip internal _sourceFile field
69
+ for (const entry of Object.values(final)) {
70
+ delete entry._sourceFile;
71
+ }
72
+
73
+ writeManifest(path.resolve(root, manifestOutput), final);
74
+ },
75
+
76
+ // Dev server: watch components dir and rebuild manifest on change
77
+ configureServer(server) {
78
+ const dir = path.resolve(root, componentsDir);
79
+ 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);
84
+ }
85
+ });
86
+ },
87
+ }, options);
88
+ }
89
+
90
+ function buildManifest(componentsDir) {
91
+ const manifest = {};
92
+
93
+ if (!fs.existsSync(componentsDir)) return manifest;
94
+
95
+ const files = walkDir(componentsDir).filter((f) =>
96
+ /\.(jsx?|tsx?)$/.test(f)
97
+ );
98
+
99
+ for (const file of files) {
100
+ const content = fs.readFileSync(file, "utf8");
101
+ if (!hasUseClient(content)) continue;
102
+
103
+ const exports = extractExportNames(content);
104
+ const relUrl = "/" + path.relative(componentsDir, file);
105
+
106
+ for (const name of exports) {
107
+ manifest[name] = {
108
+ id: relUrl,
109
+ name,
110
+ chunks: [relUrl],
111
+ _sourceFile: file, // used during build to match hashed chunks
112
+ };
113
+ }
114
+ }
115
+
116
+ return manifest;
117
+ }
118
+
119
+ function hasUseClient(content) {
120
+ // "use client" must appear as a directive at the top of the file
121
+ return /^\s*["']use client["']/m.test(content);
122
+ }
123
+
124
+ function extractExportNames(content) {
125
+ const names = new Set();
126
+
127
+ // export function Foo
128
+ // export const Foo
129
+ // export class Foo
130
+ const namedRe = /export\s+(?:default\s+)?(?:function|const|class|let|var)\s+([A-Z][A-Za-z0-9]*)/g;
131
+ let m;
132
+ while ((m = namedRe.exec(content)) !== null) {
133
+ names.add(m[1]);
134
+ }
135
+
136
+ // export { Foo, Bar }
137
+ const bracedRe = /export\s+\{([^}]+)\}/g;
138
+ while ((m = bracedRe.exec(content)) !== null) {
139
+ for (const part of m[1].split(",")) {
140
+ const name = part.trim().split(/\s+as\s+/).pop().trim();
141
+ if (/^[A-Z]/.test(name)) names.add(name);
142
+ }
143
+ }
144
+
145
+ return Array.from(names);
146
+ }
147
+
148
+ function writeManifest(outputPath, manifest) {
149
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
150
+ fs.writeFileSync(outputPath, JSON.stringify(manifest, null, 2));
151
+ }
152
+
153
+ function walkDir(dir) {
154
+ const results = [];
155
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
156
+ const full = path.join(dir, entry.name);
157
+ if (entry.isDirectory()) {
158
+ results.push(...walkDir(full));
159
+ } else {
160
+ results.push(full);
161
+ }
162
+ }
163
+ return results;
164
+ }