vite-plugin-nora 0.1.0

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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +26 -0
  3. package/README.md +113 -0
  4. package/licenses/geist-OFL.txt +93 -0
  5. package/licenses/lucide-ISC.txt +17 -0
  6. package/package.json +90 -0
  7. package/src/cli.js +101 -0
  8. package/src/client/App.jsx +216 -0
  9. package/src/client/canvas/Canvas.jsx +95 -0
  10. package/src/client/canvas/ErrorBoundary.jsx +56 -0
  11. package/src/client/canvas/Preview.jsx +70 -0
  12. package/src/client/canvas/Viewport.jsx +211 -0
  13. package/src/client/chords.js +38 -0
  14. package/src/client/css.d.ts +5 -0
  15. package/src/client/fonts.css +48 -0
  16. package/src/client/frame.css +107 -0
  17. package/src/client/frame.html +11 -0
  18. package/src/client/frame.jsx +73 -0
  19. package/src/client/index.html +12 -0
  20. package/src/client/main.jsx +10 -0
  21. package/src/client/open-folder.js +84 -0
  22. package/src/client/selection.js +22 -0
  23. package/src/client/styles.css +1264 -0
  24. package/src/client/sweep/SweepPanel.jsx +206 -0
  25. package/src/client/sweep/measure.js +230 -0
  26. package/src/client/sweep/report.js +54 -0
  27. package/src/client/sweep/run-sweep.js +185 -0
  28. package/src/client/toolbar/Picker.jsx +343 -0
  29. package/src/client/toolbar/Toolbar.jsx +231 -0
  30. package/src/client/toolbar/ViewportMenu.jsx +83 -0
  31. package/src/client/toolbar/bar-shape.js +402 -0
  32. package/src/client/toolbar/icons.jsx +129 -0
  33. package/src/client/toolbar/use-draggable-bar.js +204 -0
  34. package/src/client/viewports.js +43 -0
  35. package/src/client/virtual.d.ts +28 -0
  36. package/src/index.js +4 -0
  37. package/src/server/create-server.js +147 -0
  38. package/src/server/plugin.js +207 -0
  39. package/src/server/safe-path.js +42 -0
  40. package/src/server/scan.js +350 -0
  41. package/types/index.d.ts +3 -0
  42. package/types/server/create-server.d.ts +22 -0
  43. package/types/server/plugin.d.ts +20 -0
  44. package/types/server/safe-path.d.ts +25 -0
  45. package/types/server/scan.d.ts +106 -0
@@ -0,0 +1,350 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs/promises";
3
+ import fg from "fast-glob";
4
+ import { transform } from "esbuild";
5
+ import { init as initLexer, parse as lexParse } from "es-module-lexer";
6
+
7
+ const DEFAULT_INCLUDE = ["**/*.{tsx,jsx}"];
8
+
9
+ /**
10
+ * Skipped unless a project says otherwise.
11
+ *
12
+ * The index entries are the ones worth knowing about. They are here because an
13
+ * `index.js` is usually a barrel that re-exports its neighbours, and listing it
14
+ * would show every component in the folder twice. But a project whose screens
15
+ * live in `index.jsx` — one folder per screen, the screen at its root — is a
16
+ * real convention, and under this default nora cannot see those screens at all.
17
+ *
18
+ * That is a configuration problem rather than a scanning one: set `include` and
19
+ * `exclude` in nora.config to drop the index lines, and the folder's own design
20
+ * becomes visible to `pickEntry` like anything else.
21
+ */
22
+ const DEFAULT_EXCLUDE = [
23
+ "**/node_modules/**",
24
+ "**/dist/**",
25
+ "**/build/**",
26
+ "**/.next/**",
27
+ "**/*.test.*",
28
+ "**/*.spec.*",
29
+ "**/*.stories.*",
30
+ "**/index.ts",
31
+ "**/index.tsx",
32
+ "**/index.js",
33
+ "**/index.jsx",
34
+ ];
35
+
36
+ /** Directories never worth showing in the folder picker. */
37
+ const HIDDEN_DIRS = new Set([
38
+ "node_modules",
39
+ ".git",
40
+ "dist",
41
+ "build",
42
+ ".next",
43
+ ".cache",
44
+ ".vite",
45
+ "coverage",
46
+ ".turbo",
47
+ ".svelte-kit",
48
+ ]);
49
+
50
+ /**
51
+ * A component looks like an exported binding whose name starts uppercase.
52
+ * This is a heuristic and it will be wrong sometimes — which is why the client
53
+ * keeps a "show all exports" affordance rather than treating it as truth.
54
+ */
55
+ const looksLikeComponent = (name) => /^[A-Z]/.test(name);
56
+
57
+ /**
58
+ * Read a source file and return its export names, without executing it.
59
+ *
60
+ * es-module-lexer can't read TSX directly, so esbuild strips types first.
61
+ * esbuild ships with Vite, costs about a millisecond a file, and gets generics
62
+ * and decorators right — all things a regex over the source would not.
63
+ *
64
+ * @param {string} file absolute path
65
+ */
66
+ async function readExports(file) {
67
+ const source = await fs.readFile(file, "utf8");
68
+
69
+ // Directive detection has to happen on the raw source: esbuild strips
70
+ // "use server" during transform, and an RSC cannot render in our canvas.
71
+ const head = source.slice(0, 400);
72
+ const isServerOnly = /^\s*["']use server["']/m.test(head);
73
+
74
+ const loader = file.endsWith(".tsx") ? "tsx" : file.endsWith(".ts") ? "ts" : "jsx";
75
+
76
+ let code;
77
+ try {
78
+ ({ code } = await transform(source, { loader, format: "esm" }));
79
+ } catch (err) {
80
+ return { exports: [], isServerOnly, error: String(err.message ?? err) };
81
+ }
82
+
83
+ await initLexer;
84
+ try {
85
+ // The lexer returns imports first. They used to be discarded; they are what
86
+ // lets `pickEntry` tell a composition root from a leaf, and they cost
87
+ // nothing because the parse happens anyway.
88
+ const [imp, exp] = lexParse(code, file);
89
+ return {
90
+ // The lexer's Export/Import unions include variants with no `n` (a
91
+ // `export * from` re-export, a dynamic import with a computed specifier).
92
+ // Both come back undefined rather than throwing, which the filter below
93
+ // and `looksLikeComponent` downstream already handle.
94
+ exports: exp.map((e) => /** @type {{ n?: string }} */ (e).n).filter(Boolean),
95
+ imports: imp.map((i) => /** @type {{ n?: string }} */ (i).n).filter(Boolean),
96
+ isServerOnly,
97
+ error: null,
98
+ };
99
+ } catch (err) {
100
+ return { exports: [], imports: [], isServerOnly, error: String(err.message ?? err) };
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Resolve a relative import onto the set of files actually scanned.
106
+ *
107
+ * Only relative specifiers are followed. A bare or aliased one ("react",
108
+ * "@ui/tokens.js") cannot be resolved without the project's resolver config,
109
+ * and for this purpose it does not need to be: the question is only ever
110
+ * "does this file pull in a sibling of its own", and a sibling is relative.
111
+ */
112
+ function resolveSibling(file, spec, files) {
113
+ const parts = file.split("/").slice(0, -1);
114
+ for (const piece of spec.split("/")) {
115
+ if (piece === "" || piece === ".") continue;
116
+ if (piece === "..") parts.pop();
117
+ else parts.push(piece);
118
+ }
119
+ const joined = parts.join("/");
120
+ if (files.has(joined)) return joined;
121
+ for (const ext of [".jsx", ".tsx", ".js", ".ts"]) {
122
+ if (files.has(joined + ext)) return joined + ext;
123
+ }
124
+ return null;
125
+ }
126
+
127
+ const normalise = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
128
+
129
+ /**
130
+ * Which of a folder's entries *is* the folder — the design the rest compose into.
131
+ *
132
+ * The question this answers is "you pointed nora at a project folder; what did
133
+ * you want to look at". A folder of a dozen components usually has one that is
134
+ * the actual screen and eleven that are parts of it, and landing on the screen
135
+ * is the difference between the tool opening on your work and opening on a list.
136
+ *
137
+ * The load-bearing rule is the second one. A composition root is a file that
138
+ * imports siblings and that no sibling imports — which is close to a definition
139
+ * of "the top of this folder" rather than a guess about naming, and it is why
140
+ * the imports above are collected at all. Ties break toward the file that pulls
141
+ * in the most, since that is the fullest assembly of the folder.
142
+ *
143
+ * Everything around it is scaffolding: an explicit config entry wins outright
144
+ * because the author said so, a name matching the folder catches the flat case
145
+ * where nothing imports anything, and first-alphabetically keeps the result
146
+ * deterministic rather than dependent on filesystem order.
147
+ *
148
+ * Pure on purpose — it takes plain data, so it is testable without a
149
+ * filesystem, a bundler, or a platform-matched esbuild.
150
+ *
151
+ * @param {object} opts
152
+ * @param {Array} opts.entries scanned entries, in file order
153
+ * @param {Map<string,string[]>} opts.imports rel file -> its import specifiers
154
+ * @param {string} [opts.folder] the folder's own name, for the naming rule
155
+ * @param {string} [opts.override] a name/id/file from nora.config
156
+ * @returns {string|null} the chosen entry id
157
+ */
158
+ export function pickEntry({ entries, imports, folder, override }) {
159
+ const live = (entries ?? []).filter((e) => !e.unsupported);
160
+ if (!live.length) return null;
161
+
162
+ if (override) {
163
+ const hit = live.find((e) => e.name === override || e.id === override || e.file === override);
164
+ if (hit) return hit.id;
165
+ }
166
+
167
+ const files = new Set(live.map((e) => e.file));
168
+ const pulls = new Map();
169
+ const pulledBy = new Map();
170
+
171
+ for (const [file, specs] of imports ?? []) {
172
+ if (!files.has(file)) continue;
173
+ for (const spec of specs) {
174
+ if (!spec.startsWith(".")) continue;
175
+ const target = resolveSibling(file, spec, files);
176
+ if (!target || target === file) continue;
177
+ pulls.set(file, (pulls.get(file) ?? 0) + 1);
178
+ pulledBy.set(target, (pulledBy.get(target) ?? 0) + 1);
179
+ }
180
+ }
181
+
182
+ const roots = live.filter(
183
+ (e) => (pulls.get(e.file) ?? 0) > 0 && (pulledBy.get(e.file) ?? 0) === 0,
184
+ );
185
+ if (roots.length) {
186
+ roots.sort(
187
+ (a, b) => (pulls.get(b.file) ?? 0) - (pulls.get(a.file) ?? 0) || a.file.localeCompare(b.file),
188
+ );
189
+ return roots[0].id;
190
+ }
191
+
192
+ if (folder) {
193
+ const want = normalise(folder);
194
+ const named =
195
+ live.find((e) => normalise(e.name) === want) ??
196
+ live.find((e) => normalise(e.name).startsWith(want));
197
+ if (named) return named.id;
198
+ }
199
+
200
+ return live[0].id;
201
+ }
202
+
203
+ /**
204
+ * Turn a directory into a flat list of registry entries — one per exported
205
+ * component, not one per file — plus which of them is the folder's own design.
206
+ *
207
+ * @param {object} opts
208
+ * @param {string} opts.root project root (all paths are reported relative to it)
209
+ * @param {string} opts.dir absolute directory to scan
210
+ * @param {string[]} [opts.include]
211
+ * @param {string[]} [opts.exclude]
212
+ * @param {string} [opts.entry] nora.config override for this folder
213
+ * @returns {Promise<{entries: Array, entryId: string|null}>}
214
+ */
215
+ export async function scanDirectory({ root, dir, include, exclude, entry }) {
216
+ const files = await fg(include?.length ? include : DEFAULT_INCLUDE, {
217
+ cwd: dir,
218
+ absolute: true,
219
+ ignore: [...DEFAULT_EXCLUDE, ...(exclude ?? [])],
220
+ onlyFiles: true,
221
+ suppressErrors: true,
222
+ });
223
+
224
+ files.sort();
225
+
226
+ const entries = [];
227
+ const imports = new Map();
228
+
229
+ for (const file of files) {
230
+ const { exports, imports: fileImports, isServerOnly, error } = await readExports(file);
231
+ const relFile = path.relative(root, file).split(path.sep).join("/");
232
+ const url = "/" + relFile;
233
+ const base = path.basename(file).replace(/\.(tsx|jsx|ts|js)$/, "");
234
+ const group = path.relative(dir, path.dirname(file)).split(path.sep).filter(Boolean).join("/");
235
+
236
+ if (error) {
237
+ entries.push({
238
+ id: `${relFile}#__error`,
239
+ name: base,
240
+ file: relFile,
241
+ url,
242
+ exportName: "default",
243
+ group,
244
+ unsupported: `Could not parse: ${error}`,
245
+ });
246
+ continue;
247
+ }
248
+
249
+ imports.set(relFile, fileImports ?? []);
250
+
251
+ for (const exportName of exports) {
252
+ const name = exportName === "default" ? base : exportName;
253
+ if (!looksLikeComponent(name)) continue;
254
+
255
+ entries.push({
256
+ id: `${relFile}#${exportName}`,
257
+ name,
258
+ file: relFile,
259
+ url,
260
+ exportName,
261
+ group,
262
+ unsupported: isServerOnly
263
+ ? 'This file is marked "use server" and cannot render in a client canvas.'
264
+ : null,
265
+ });
266
+ }
267
+ }
268
+
269
+ return {
270
+ entries,
271
+ entryId: pickEntry({
272
+ entries,
273
+ imports,
274
+ folder: path.basename(dir),
275
+ override: entry,
276
+ }),
277
+ };
278
+ }
279
+
280
+ /**
281
+ * The half of an entry that crosses into the browser.
282
+ *
283
+ * `plugin.js` used to rebuild this object by hand while generating the registry
284
+ * module, which meant the shape existed in two places and adding a field here
285
+ * reached the client as `undefined`. It is written once, here, next to the code
286
+ * that builds the entry in the first place.
287
+ *
288
+ * `url` is deliberately absent: it is the server's import specifier, consumed
289
+ * while generating the module and meaningless to the client afterwards.
290
+ *
291
+ * @typedef {object} ScannedEntry
292
+ * @property {string} id
293
+ * @property {string} name
294
+ * @property {string} file
295
+ * @property {string} url import specifier, server-side only
296
+ * @property {string} exportName
297
+ * @property {string} group
298
+ * @property {string | null} [unsupported]
299
+ *
300
+ * @param {ScannedEntry} e a scanned entry
301
+ */
302
+ export function clientEntry(e) {
303
+ return {
304
+ id: e.id,
305
+ name: e.name,
306
+ file: e.file,
307
+ exportName: e.exportName,
308
+ group: e.group,
309
+ unsupported: e.unsupported ?? null,
310
+ };
311
+ }
312
+
313
+ /**
314
+ * List immediate subdirectories of `dir`, each with a count of candidate
315
+ * component files beneath it, so the picker can show "24" next to a folder.
316
+ *
317
+ * @param {string} dir absolute directory
318
+ */
319
+ export async function listDirectories(dir) {
320
+ let dirents;
321
+ try {
322
+ dirents = await fs.readdir(dir, { withFileTypes: true });
323
+ } catch {
324
+ return [];
325
+ }
326
+
327
+ const dirs = dirents
328
+ .filter((d) => d.isDirectory() && !HIDDEN_DIRS.has(d.name) && !d.name.startsWith("."))
329
+ .map((d) => d.name)
330
+ .sort();
331
+
332
+ return Promise.all(
333
+ dirs.map(async (name) => {
334
+ const full = path.join(dir, name);
335
+ let count = 0;
336
+ try {
337
+ const found = await fg(DEFAULT_INCLUDE, {
338
+ cwd: full,
339
+ ignore: DEFAULT_EXCLUDE,
340
+ onlyFiles: true,
341
+ suppressErrors: true,
342
+ });
343
+ count = found.length;
344
+ } catch {
345
+ /* unreadable directory — report it with a zero count rather than hiding it */
346
+ }
347
+ return { name, count };
348
+ }),
349
+ );
350
+ }
@@ -0,0 +1,3 @@
1
+ export { createPreviewServer, findProjectRoot } from "./server/create-server.js";
2
+ export { nora } from "./server/plugin.js";
3
+ export { scanDirectory, listDirectories } from "./server/scan.js";
@@ -0,0 +1,22 @@
1
+ /** Walk up from `start` looking for a package.json to treat as the project root. */
2
+ export declare function findProjectRoot(start: any): string;
3
+ /**
4
+ * Boot a Vite dev server rooted in the user's project, inheriting their config,
5
+ * and serve our client through it.
6
+ *
7
+ * @param {object} opts
8
+ * @param {string} opts.root
9
+ * @param {number} [opts.port]
10
+ * @param {string|null} [opts.dir] relative folder to open on boot
11
+ */
12
+ export declare function createPreviewServer({ root, port, dir }: {
13
+ root: string;
14
+ port?: number;
15
+ dir?: string | null;
16
+ }): Promise<{
17
+ server: import("vite").ViteDevServer;
18
+ url: string;
19
+ inheritedConfig: boolean;
20
+ configPath: string;
21
+ addedReactPlugin: boolean;
22
+ }>;
@@ -0,0 +1,20 @@
1
+ declare const VIRTUAL_ID = "virtual:nora/registry";
2
+ declare const RESOLVED_ID: string;
3
+ /**
4
+ * The Vite plugin: owns the virtual registry module and the /__nora/* control
5
+ * endpoints the toolbar talks to.
6
+ *
7
+ * @param {object} opts
8
+ * @param {string} opts.root absolute project root
9
+ * @param {string} [opts.initialDir] relative dir to open on boot
10
+ */
11
+ export declare function nora({ root, initialDir }: {
12
+ root: string;
13
+ initialDir?: string;
14
+ }): {
15
+ name: string;
16
+ resolveId(id: any): string;
17
+ load(id: any): Promise<string>;
18
+ configureServer(server: any): void;
19
+ };
20
+ export { VIRTUAL_ID, RESOLVED_ID };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Resolve a caller-supplied path against the project root and refuse anything
3
+ * that escapes it.
4
+ *
5
+ * Both `/__nora/dirs` and `/__nora/scan` take a path straight off an HTTP request
6
+ * and hand it to `fs`. Without this clamp, `?path=../../../../etc` walks the
7
+ * whole disk of whoever is running the CLI.
8
+ *
9
+ * @param {string} root absolute project root
10
+ * @param {string} [p] untrusted relative path
11
+ * @returns {string} absolute path guaranteed to sit inside root
12
+ */
13
+ export declare function safeResolve(root: string, p?: string): string;
14
+ /**
15
+ * Reject requests whose Host header is not a loopback address.
16
+ *
17
+ * A dev server bound to localhost is still reachable from any page in the
18
+ * user's browser via DNS rebinding — an attacker's site resolves their domain
19
+ * to 127.0.0.1 and then talks to us with their own Host header. Vite has
20
+ * shipped advisories for exactly this shape of bug.
21
+ *
22
+ * @param {import('node:http').IncomingMessage} req
23
+ * @returns {boolean}
24
+ */
25
+ export declare function isLoopbackHost(req: import('node:http').IncomingMessage): boolean;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Which of a folder's entries *is* the folder — the design the rest compose into.
3
+ *
4
+ * The question this answers is "you pointed nora at a project folder; what did
5
+ * you want to look at". A folder of a dozen components usually has one that is
6
+ * the actual screen and eleven that are parts of it, and landing on the screen
7
+ * is the difference between the tool opening on your work and opening on a list.
8
+ *
9
+ * The load-bearing rule is the second one. A composition root is a file that
10
+ * imports siblings and that no sibling imports — which is close to a definition
11
+ * of "the top of this folder" rather than a guess about naming, and it is why
12
+ * the imports above are collected at all. Ties break toward the file that pulls
13
+ * in the most, since that is the fullest assembly of the folder.
14
+ *
15
+ * Everything around it is scaffolding: an explicit config entry wins outright
16
+ * because the author said so, a name matching the folder catches the flat case
17
+ * where nothing imports anything, and first-alphabetically keeps the result
18
+ * deterministic rather than dependent on filesystem order.
19
+ *
20
+ * Pure on purpose — it takes plain data, so it is testable without a
21
+ * filesystem, a bundler, or a platform-matched esbuild.
22
+ *
23
+ * @param {object} opts
24
+ * @param {Array} opts.entries scanned entries, in file order
25
+ * @param {Map<string,string[]>} opts.imports rel file -> its import specifiers
26
+ * @param {string} [opts.folder] the folder's own name, for the naming rule
27
+ * @param {string} [opts.override] a name/id/file from nora.config
28
+ * @returns {string|null} the chosen entry id
29
+ */
30
+ export declare function pickEntry({ entries, imports, folder, override }: {
31
+ entries: any[];
32
+ imports: Map<string, string[]>;
33
+ folder?: string;
34
+ override?: string;
35
+ }): string | null;
36
+ /**
37
+ * Turn a directory into a flat list of registry entries — one per exported
38
+ * component, not one per file — plus which of them is the folder's own design.
39
+ *
40
+ * @param {object} opts
41
+ * @param {string} opts.root project root (all paths are reported relative to it)
42
+ * @param {string} opts.dir absolute directory to scan
43
+ * @param {string[]} [opts.include]
44
+ * @param {string[]} [opts.exclude]
45
+ * @param {string} [opts.entry] nora.config override for this folder
46
+ * @returns {Promise<{entries: Array, entryId: string|null}>}
47
+ */
48
+ export declare function scanDirectory({ root, dir, include, exclude, entry }: {
49
+ root: string;
50
+ dir: string;
51
+ include?: string[];
52
+ exclude?: string[];
53
+ entry?: string;
54
+ }): Promise<{
55
+ entries: any[];
56
+ entryId: string | null;
57
+ }>;
58
+ export type ScannedEntry = {
59
+ id: string;
60
+ name: string;
61
+ file: string;
62
+ /**
63
+ * import specifier, server-side only
64
+ */
65
+ url: string;
66
+ exportName: string;
67
+ group: string;
68
+ unsupported?: string | null;
69
+ };
70
+ /**
71
+ * The half of an entry that crosses into the browser.
72
+ *
73
+ * `plugin.js` used to rebuild this object by hand while generating the registry
74
+ * module, which meant the shape existed in two places and adding a field here
75
+ * reached the client as `undefined`. It is written once, here, next to the code
76
+ * that builds the entry in the first place.
77
+ *
78
+ * `url` is deliberately absent: it is the server's import specifier, consumed
79
+ * while generating the module and meaningless to the client afterwards.
80
+ *
81
+ * @typedef {object} ScannedEntry
82
+ * @property {string} id
83
+ * @property {string} name
84
+ * @property {string} file
85
+ * @property {string} url import specifier, server-side only
86
+ * @property {string} exportName
87
+ * @property {string} group
88
+ * @property {string | null} [unsupported]
89
+ *
90
+ * @param {ScannedEntry} e a scanned entry
91
+ */
92
+ export declare function clientEntry(e: ScannedEntry): {
93
+ id: string;
94
+ name: string;
95
+ file: string;
96
+ exportName: string;
97
+ group: string;
98
+ unsupported: string;
99
+ };
100
+ /**
101
+ * List immediate subdirectories of `dir`, each with a count of candidate
102
+ * component files beneath it, so the picker can show "24" next to a folder.
103
+ *
104
+ * @param {string} dir absolute directory
105
+ */
106
+ export declare function listDirectories(dir: string): Promise<any[]>;