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.
- package/LICENSE +21 -0
- package/NOTICE +26 -0
- package/README.md +113 -0
- package/licenses/geist-OFL.txt +93 -0
- package/licenses/lucide-ISC.txt +17 -0
- package/package.json +90 -0
- package/src/cli.js +101 -0
- package/src/client/App.jsx +216 -0
- package/src/client/canvas/Canvas.jsx +95 -0
- package/src/client/canvas/ErrorBoundary.jsx +56 -0
- package/src/client/canvas/Preview.jsx +70 -0
- package/src/client/canvas/Viewport.jsx +211 -0
- package/src/client/chords.js +38 -0
- package/src/client/css.d.ts +5 -0
- package/src/client/fonts.css +48 -0
- package/src/client/frame.css +107 -0
- package/src/client/frame.html +11 -0
- package/src/client/frame.jsx +73 -0
- package/src/client/index.html +12 -0
- package/src/client/main.jsx +10 -0
- package/src/client/open-folder.js +84 -0
- package/src/client/selection.js +22 -0
- package/src/client/styles.css +1264 -0
- package/src/client/sweep/SweepPanel.jsx +206 -0
- package/src/client/sweep/measure.js +230 -0
- package/src/client/sweep/report.js +54 -0
- package/src/client/sweep/run-sweep.js +185 -0
- package/src/client/toolbar/Picker.jsx +343 -0
- package/src/client/toolbar/Toolbar.jsx +231 -0
- package/src/client/toolbar/ViewportMenu.jsx +83 -0
- package/src/client/toolbar/bar-shape.js +402 -0
- package/src/client/toolbar/icons.jsx +129 -0
- package/src/client/toolbar/use-draggable-bar.js +204 -0
- package/src/client/viewports.js +43 -0
- package/src/client/virtual.d.ts +28 -0
- package/src/index.js +4 -0
- package/src/server/create-server.js +147 -0
- package/src/server/plugin.js +207 -0
- package/src/server/safe-path.js +42 -0
- package/src/server/scan.js +350 -0
- package/types/index.d.ts +3 -0
- package/types/server/create-server.d.ts +22 -0
- package/types/server/plugin.d.ts +20 -0
- package/types/server/safe-path.d.ts +25 -0
- package/types/server/scan.d.ts +106 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
const KEY = "nora:bar-position";
|
|
4
|
+
const MARGIN = 12;
|
|
5
|
+
/** Pointer travel before a press becomes a drag rather than a click. */
|
|
6
|
+
const THRESHOLD = 4;
|
|
7
|
+
|
|
8
|
+
const DEFAULT = { hside: "right", vside: "bottom", dx: 20, dy: 20 };
|
|
9
|
+
|
|
10
|
+
function read() {
|
|
11
|
+
try {
|
|
12
|
+
const raw = localStorage.getItem(KEY);
|
|
13
|
+
if (!raw) return DEFAULT;
|
|
14
|
+
const parsed = JSON.parse(raw);
|
|
15
|
+
if (!["left", "right"].includes(parsed.hside)) return DEFAULT;
|
|
16
|
+
if (!["top", "bottom"].includes(parsed.vside)) return DEFAULT;
|
|
17
|
+
return parsed;
|
|
18
|
+
} catch {
|
|
19
|
+
return DEFAULT;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function write(pos) {
|
|
24
|
+
try {
|
|
25
|
+
localStorage.setItem(KEY, JSON.stringify(pos));
|
|
26
|
+
} catch {
|
|
27
|
+
/* storage disabled — the bar just won't remember where you put it */
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Turn a pill rectangle into offsets from whichever corner it now sits nearest.
|
|
33
|
+
*
|
|
34
|
+
* Anchoring to the nearest corner rather than always to the top-left is what
|
|
35
|
+
* makes the panels behave: the bar grows away from the edge it is pinned to, so
|
|
36
|
+
* a bar at the bottom opens its panels upward and one at the top opens them
|
|
37
|
+
* downward, with no measuring or repositioning after the fact.
|
|
38
|
+
*/
|
|
39
|
+
function toAnchor(rect) {
|
|
40
|
+
const { innerWidth: vw, innerHeight: vh } = window;
|
|
41
|
+
const hside = rect.left + rect.width / 2 < vw / 2 ? "left" : "right";
|
|
42
|
+
const vside = rect.top + rect.height / 2 < vh / 2 ? "top" : "bottom";
|
|
43
|
+
return {
|
|
44
|
+
hside,
|
|
45
|
+
vside,
|
|
46
|
+
dx: Math.round(hside === "left" ? rect.left : vw - (rect.left + rect.width)),
|
|
47
|
+
dy: Math.round(vside === "top" ? rect.top : vh - (rect.top + rect.height)),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Keep the bar reachable however the window is resized. */
|
|
52
|
+
function clampAnchor(pos, size) {
|
|
53
|
+
const { innerWidth: vw, innerHeight: vh } = window;
|
|
54
|
+
const maxX = Math.max(MARGIN, vw - size.width - MARGIN);
|
|
55
|
+
const maxY = Math.max(MARGIN, vh - size.height - MARGIN);
|
|
56
|
+
return {
|
|
57
|
+
...pos,
|
|
58
|
+
dx: Math.min(Math.max(pos.dx, MARGIN), maxX),
|
|
59
|
+
dy: Math.min(Math.max(pos.dy, MARGIN), maxY),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Drag the bar anywhere on screen.
|
|
65
|
+
*
|
|
66
|
+
* A press anywhere on the pill can start a drag, buttons included — moving a
|
|
67
|
+
* floating object by grabbing any part of it is the expected gesture, and
|
|
68
|
+
* restricting drags to a slim handle would leave most of an expanded pill
|
|
69
|
+
* undraggable. A press only becomes a drag past a few pixels of travel, and a
|
|
70
|
+
* drag swallows the click that would otherwise follow, so buttons still work
|
|
71
|
+
* exactly as before.
|
|
72
|
+
*/
|
|
73
|
+
export function useDraggableBar(pillRef, expanded) {
|
|
74
|
+
const [pos, setPos] = useState(read);
|
|
75
|
+
const [dragging, setDragging] = useState(false);
|
|
76
|
+
const start = useRef(null);
|
|
77
|
+
const first = useRef(true);
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (!dragging) write(pos);
|
|
81
|
+
}, [pos, dragging]);
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
const onResize = () => {
|
|
85
|
+
const rect = pillRef.current?.getBoundingClientRect();
|
|
86
|
+
if (rect) setPos((p) => clampAnchor(p, rect));
|
|
87
|
+
};
|
|
88
|
+
window.addEventListener("resize", onResize);
|
|
89
|
+
return () => window.removeEventListener("resize", onResize);
|
|
90
|
+
}, [pillRef]);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Hold the bar's right edge still when it opens and shuts.
|
|
94
|
+
*
|
|
95
|
+
* Collapse is the last control on the bar, so the circle the bar shuts into
|
|
96
|
+
* belongs underneath that button rather than at the far end from it: the
|
|
97
|
+
* shape should close toward the thing you just pressed, not away from it.
|
|
98
|
+
*
|
|
99
|
+
* A right-anchored bar gets that for nothing, because the edge being held is
|
|
100
|
+
* already the right one. A left-anchored bar is holding the wrong edge, so it
|
|
101
|
+
* shrinks leftward and leaves the circle 418px from the button that produced
|
|
102
|
+
* it. The anchor therefore moves across by exactly the width just given up,
|
|
103
|
+
* and moves back when the bar opens again.
|
|
104
|
+
*
|
|
105
|
+
* This is a real change of anchor rather than a transform, and deliberately
|
|
106
|
+
* so. Dragging and edge-clamping both reason about the pill's box; a purely
|
|
107
|
+
* visual offset would leave them reasoning about a box nobody can see, and
|
|
108
|
+
* the circle would jump the first time you picked it up. Because the offset
|
|
109
|
+
* is re-derived from `dx` on every toggle rather than remembered, a drag in
|
|
110
|
+
* between is absorbed for free — `toAnchor` has already rewritten `dx` from
|
|
111
|
+
* the box the user actually left it in.
|
|
112
|
+
*/
|
|
113
|
+
useLayoutEffect(() => {
|
|
114
|
+
const pill = pillRef.current;
|
|
115
|
+
if (!pill) return;
|
|
116
|
+
if (first.current) {
|
|
117
|
+
first.current = false;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const full = parseFloat(getComputedStyle(pill).getPropertyValue("--nora-bar-w"));
|
|
122
|
+
const shut = pill.offsetHeight;
|
|
123
|
+
const give = full - shut;
|
|
124
|
+
if (!Number.isFinite(give) || give <= 0) return;
|
|
125
|
+
|
|
126
|
+
setPos((p) =>
|
|
127
|
+
p.hside === "left"
|
|
128
|
+
? clampAnchor(
|
|
129
|
+
{ ...p, dx: p.dx + (expanded ? -give : give) },
|
|
130
|
+
{ width: expanded ? full : shut, height: shut },
|
|
131
|
+
)
|
|
132
|
+
: p,
|
|
133
|
+
);
|
|
134
|
+
}, [expanded, pillRef]);
|
|
135
|
+
|
|
136
|
+
const onPointerDown = useCallback(
|
|
137
|
+
(event) => {
|
|
138
|
+
if (event.button !== 0) return;
|
|
139
|
+
const pill = pillRef.current;
|
|
140
|
+
if (!pill) return;
|
|
141
|
+
|
|
142
|
+
const rect = pill.getBoundingClientRect();
|
|
143
|
+
start.current = {
|
|
144
|
+
pointerX: event.clientX,
|
|
145
|
+
pointerY: event.clientY,
|
|
146
|
+
left: rect.left,
|
|
147
|
+
top: rect.top,
|
|
148
|
+
width: rect.width,
|
|
149
|
+
height: rect.height,
|
|
150
|
+
moved: false,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const onMove = (moveEvent) => {
|
|
154
|
+
const s = start.current;
|
|
155
|
+
if (!s) return;
|
|
156
|
+
const dx = moveEvent.clientX - s.pointerX;
|
|
157
|
+
const dy = moveEvent.clientY - s.pointerY;
|
|
158
|
+
|
|
159
|
+
if (!s.moved) {
|
|
160
|
+
if (Math.hypot(dx, dy) < THRESHOLD) return;
|
|
161
|
+
s.moved = true;
|
|
162
|
+
setDragging(true);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const { innerWidth: vw, innerHeight: vh } = window;
|
|
166
|
+
const left = Math.min(Math.max(s.left + dx, MARGIN), vw - s.width - MARGIN);
|
|
167
|
+
const top = Math.min(Math.max(s.top + dy, MARGIN), vh - s.height - MARGIN);
|
|
168
|
+
setPos(toAnchor({ left, top, width: s.width, height: s.height }));
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const onUp = () => {
|
|
172
|
+
window.removeEventListener("pointermove", onMove);
|
|
173
|
+
window.removeEventListener("pointerup", onUp);
|
|
174
|
+
const moved = start.current?.moved;
|
|
175
|
+
start.current = null;
|
|
176
|
+
setDragging(false);
|
|
177
|
+
|
|
178
|
+
// Swallow the click this press would otherwise produce, so dragging
|
|
179
|
+
// from a button doesn't also press it.
|
|
180
|
+
if (moved) {
|
|
181
|
+
const swallow = (clickEvent) => {
|
|
182
|
+
clickEvent.stopPropagation();
|
|
183
|
+
clickEvent.preventDefault();
|
|
184
|
+
};
|
|
185
|
+
window.addEventListener("click", swallow, { capture: true, once: true });
|
|
186
|
+
setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
window.addEventListener("pointermove", onMove);
|
|
191
|
+
window.addEventListener("pointerup", onUp);
|
|
192
|
+
},
|
|
193
|
+
[pillRef],
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const style = {
|
|
197
|
+
[pos.hside]: `${pos.dx}px`,
|
|
198
|
+
[pos.vside]: `${pos.dy}px`,
|
|
199
|
+
[pos.hside === "left" ? "right" : "left"]: "auto",
|
|
200
|
+
[pos.vside === "top" ? "bottom" : "top"]: "auto",
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
return { pos, style, dragging, onPointerDown };
|
|
204
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Viewport presets.
|
|
3
|
+
*
|
|
4
|
+
* These are real device widths, and they only mean anything because the
|
|
5
|
+
* component renders inside an iframe. A CSS width on a div would constrain the
|
|
6
|
+
* box but leave `@media (max-width: 767px)` unfired — media queries answer to
|
|
7
|
+
* the viewport, not to an element. The iframe *is* a viewport.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* A chosen viewport width.
|
|
11
|
+
*
|
|
12
|
+
* @typedef {object} NoraViewport
|
|
13
|
+
* @property {string} id
|
|
14
|
+
* @property {string} label what the bar's width button shows
|
|
15
|
+
* @property {number | null} width null fits the canvas instead of framing it
|
|
16
|
+
* @property {string | null} [note] the aside in the viewport menu
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** @type {NoraViewport[]} */
|
|
20
|
+
export const VIEWPORTS = [
|
|
21
|
+
{ id: "fit", label: "Fit", width: null, note: null },
|
|
22
|
+
{ id: "375", label: "375", width: 375, note: "Phone" },
|
|
23
|
+
{ id: "768", label: "768", width: 768, note: "Tablet" },
|
|
24
|
+
{ id: "1280", label: "1280", width: 1280, note: "Laptop" },
|
|
25
|
+
{ id: "1440", label: "1440", width: 1440, note: "Desktop" },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export const DEFAULT_VIEWPORT = "fit";
|
|
29
|
+
|
|
30
|
+
export function findViewport(id) {
|
|
31
|
+
return VIEWPORTS.find((v) => v.id === id) ?? VIEWPORTS[0];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Any width, not just a preset. A sweep finding is a width, and clicking one
|
|
36
|
+
* has to be able to send the frame there — 771px is exactly the number you
|
|
37
|
+
* care about and exactly the one no preset list would contain.
|
|
38
|
+
*/
|
|
39
|
+
export function customViewport(width) {
|
|
40
|
+
const preset = VIEWPORTS.find((v) => v.width === width);
|
|
41
|
+
if (preset) return preset;
|
|
42
|
+
return { id: `w${width}`, label: String(width), width, note: null };
|
|
43
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The registry module the Vite plugin generates at dev time.
|
|
3
|
+
*
|
|
4
|
+
* It has no file on disk — `plugin.js` resolves and loads `virtual:nora/registry`
|
|
5
|
+
* itself — so this is the only place its shape is written down. Keep it in step
|
|
6
|
+
* with the `load()` hook there.
|
|
7
|
+
*/
|
|
8
|
+
declare module "virtual:nora/registry" {
|
|
9
|
+
export interface NoraEntry {
|
|
10
|
+
/** `<relative file>#<export name>` — stable across reloads. */
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
/** Path relative to the project root. */
|
|
14
|
+
file: string;
|
|
15
|
+
exportName: string;
|
|
16
|
+
/** Sub-path within the scanned folder, "" at its top level. */
|
|
17
|
+
group: string;
|
|
18
|
+
/** Why this cannot render, or null when it can. */
|
|
19
|
+
unsupported: string | null;
|
|
20
|
+
load: () => Promise<Record<string, unknown>>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const entries: NoraEntry[];
|
|
24
|
+
export const currentDir: string | null;
|
|
25
|
+
/** The folder's own top-level design, chosen by `pickEntry`. */
|
|
26
|
+
export const entryId: string | null;
|
|
27
|
+
export const config: Record<string, unknown>;
|
|
28
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Programmatic entry, for embedding the previewer in another tool's dev server.
|
|
2
|
+
export { createPreviewServer, findProjectRoot } from "./server/create-server.js";
|
|
3
|
+
export { nora } from "./server/plugin.js";
|
|
4
|
+
export { scanDirectory, listDirectories } from "./server/scan.js";
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { createServer, loadConfigFromFile, mergeConfig } from "vite";
|
|
5
|
+
import { nora } from "./plugin.js";
|
|
6
|
+
|
|
7
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const CLIENT_DIR = path.resolve(here, "../client");
|
|
9
|
+
|
|
10
|
+
/** Plugin arrays can be nested, async, or contain falsy entries. Flatten them. */
|
|
11
|
+
async function flattenPlugins(plugins) {
|
|
12
|
+
const out = [];
|
|
13
|
+
for (const p of await Promise.all([plugins ?? []].flat(Infinity))) {
|
|
14
|
+
if (!p) continue;
|
|
15
|
+
if (Array.isArray(p)) out.push(...(await flattenPlugins(p)));
|
|
16
|
+
else out.push(p);
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Does their config already transform JSX? If so, adding ours would double it. */
|
|
22
|
+
function hasReactPlugin(plugins) {
|
|
23
|
+
return plugins.some((p) => typeof p?.name === "string" && /react/i.test(p.name));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Walk up from `start` looking for a package.json to treat as the project root. */
|
|
27
|
+
export function findProjectRoot(start) {
|
|
28
|
+
let dir = path.resolve(start);
|
|
29
|
+
const { root } = path.parse(dir);
|
|
30
|
+
while (true) {
|
|
31
|
+
if (fs.existsSync(path.join(dir, "package.json"))) return dir;
|
|
32
|
+
if (dir === root) return path.resolve(start);
|
|
33
|
+
dir = path.dirname(dir);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Boot a Vite dev server rooted in the user's project, inheriting their config,
|
|
39
|
+
* and serve our client through it.
|
|
40
|
+
*
|
|
41
|
+
* @param {object} opts
|
|
42
|
+
* @param {string} opts.root
|
|
43
|
+
* @param {number} [opts.port]
|
|
44
|
+
* @param {string|null} [opts.dir] relative folder to open on boot
|
|
45
|
+
*/
|
|
46
|
+
export async function createPreviewServer({ root, port = 5199, dir = null }) {
|
|
47
|
+
const loaded = await loadConfigFromFile(
|
|
48
|
+
{ command: "serve", mode: "development" },
|
|
49
|
+
undefined,
|
|
50
|
+
root,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const userConfig = loaded?.config ?? {};
|
|
54
|
+
const userPlugins = await flattenPlugins(userConfig.plugins);
|
|
55
|
+
const needsReactPlugin = !hasReactPlugin(userPlugins);
|
|
56
|
+
|
|
57
|
+
let reactPlugin = [];
|
|
58
|
+
if (needsReactPlugin) {
|
|
59
|
+
try {
|
|
60
|
+
const { default: react } = await import("@vitejs/plugin-react");
|
|
61
|
+
reactPlugin = [react()];
|
|
62
|
+
} catch {
|
|
63
|
+
// No React plugin anywhere. JSX will fail, but a non-React project may
|
|
64
|
+
// still be browsable, so warn rather than exit.
|
|
65
|
+
console.warn(
|
|
66
|
+
"[nora] no React plugin found in your Vite config and " +
|
|
67
|
+
"@vitejs/plugin-react is not installed — JSX will not compile.",
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const ours = {
|
|
73
|
+
configFile: false,
|
|
74
|
+
root,
|
|
75
|
+
// `custom` because we serve our own index.html rather than the project's.
|
|
76
|
+
appType: "custom",
|
|
77
|
+
plugins: [...reactPlugin, nora({ root, initialDir: dir })],
|
|
78
|
+
resolve: {
|
|
79
|
+
// Two copies of React means two dispatchers, and the first useState in a
|
|
80
|
+
// previewed component throws "invalid hook call". This is the line that
|
|
81
|
+
// keeps our client and their components on one instance.
|
|
82
|
+
dedupe: ["react", "react-dom"],
|
|
83
|
+
},
|
|
84
|
+
optimizeDeps: {
|
|
85
|
+
include: ["react", "react-dom", "react-dom/client", "react/jsx-dev-runtime"],
|
|
86
|
+
},
|
|
87
|
+
server: {
|
|
88
|
+
port,
|
|
89
|
+
strictPort: false,
|
|
90
|
+
host: "localhost",
|
|
91
|
+
fs: {
|
|
92
|
+
// Our client lives outside the project root, so Vite has to be allowed
|
|
93
|
+
// to serve it. Workspace roots come along via `searchForWorkspaceRoot`
|
|
94
|
+
// in Vite's own defaults.
|
|
95
|
+
allow: [root, CLIENT_DIR, path.resolve(CLIENT_DIR, "..", "..")],
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const config = mergeConfig(userConfig, ours);
|
|
101
|
+
const server = await createServer(config);
|
|
102
|
+
|
|
103
|
+
const clientUrl = "/@fs" + CLIENT_DIR.split(path.sep).join("/");
|
|
104
|
+
|
|
105
|
+
/** Serve one of our two HTML documents through Vite's transform pipeline. */
|
|
106
|
+
const sendHtml = async (req, res, next, template, entry) => {
|
|
107
|
+
try {
|
|
108
|
+
const raw = fs
|
|
109
|
+
.readFileSync(path.join(CLIENT_DIR, template), "utf8")
|
|
110
|
+
.replace("__CP_ENTRY__", `${clientUrl}/${entry}`);
|
|
111
|
+
const html = await server.transformIndexHtml(req.url ?? "/", raw);
|
|
112
|
+
res.statusCode = 200;
|
|
113
|
+
res.setHeader("Content-Type", "text/html");
|
|
114
|
+
res.end(html);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
server.ssrFixStacktrace?.(err);
|
|
117
|
+
next(err);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// The preview document. Loaded into an iframe by the shell so the component
|
|
122
|
+
// inside gets a real viewport — media queries and `vw` units answer to the
|
|
123
|
+
// frame's width, which is the whole point of the viewport presets.
|
|
124
|
+
server.middlewares.use("/__nora/frame", async (req, res, next) => {
|
|
125
|
+
if (req.method !== "GET") return next();
|
|
126
|
+
await sendHtml(req, res, next, "frame.html", "frame.jsx");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Last middleware: anything that isn't a module, asset, or /__cp route gets
|
|
130
|
+
// our shell. Vite's own middlewares run before this and win.
|
|
131
|
+
server.middlewares.use(async (req, res, next) => {
|
|
132
|
+
if (!req.url || req.method !== "GET") return next();
|
|
133
|
+
if (req.url.startsWith("/__nora/")) return next();
|
|
134
|
+
if (req.headers.accept && !req.headers.accept.includes("text/html")) return next();
|
|
135
|
+
await sendHtml(req, res, next, "index.html", "main.jsx");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await server.listen();
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
server,
|
|
142
|
+
url: `http://localhost:${server.config.server.port}`,
|
|
143
|
+
inheritedConfig: Boolean(loaded?.path),
|
|
144
|
+
configPath: loaded?.path ?? null,
|
|
145
|
+
addedReactPlugin: needsReactPlugin && reactPlugin.length > 0,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { clientEntry, scanDirectory, listDirectories } from "./scan.js";
|
|
4
|
+
import { safeResolve, isLoopbackHost } from "./safe-path.js";
|
|
5
|
+
|
|
6
|
+
const VIRTUAL_ID = "virtual:nora/registry";
|
|
7
|
+
const RESOLVED_ID = "\0" + VIRTUAL_ID;
|
|
8
|
+
|
|
9
|
+
const CONFIG_NAMES = ["nora.config.tsx", "nora.config.ts", "nora.config.jsx", "nora.config.js"];
|
|
10
|
+
|
|
11
|
+
/** @param {string} root */
|
|
12
|
+
function findConfigFile(root) {
|
|
13
|
+
for (const name of CONFIG_NAMES) {
|
|
14
|
+
const full = path.join(root, name);
|
|
15
|
+
if (fs.existsSync(full)) return full;
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Read a JSON request body without pulling in a body-parser. */
|
|
21
|
+
function readJson(req) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
let raw = "";
|
|
24
|
+
req.on("data", (chunk) => {
|
|
25
|
+
raw += chunk;
|
|
26
|
+
if (raw.length > 1e6) reject(new Error("Body too large"));
|
|
27
|
+
});
|
|
28
|
+
req.on("end", () => {
|
|
29
|
+
try {
|
|
30
|
+
resolve(raw ? JSON.parse(raw) : {});
|
|
31
|
+
} catch (err) {
|
|
32
|
+
reject(err);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
req.on("error", reject);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sendJson(res, status, body) {
|
|
40
|
+
res.statusCode = status;
|
|
41
|
+
res.setHeader("Content-Type", "application/json");
|
|
42
|
+
res.end(JSON.stringify(body));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The Vite plugin: owns the virtual registry module and the /__nora/* control
|
|
47
|
+
* endpoints the toolbar talks to.
|
|
48
|
+
*
|
|
49
|
+
* @param {object} opts
|
|
50
|
+
* @param {string} opts.root absolute project root
|
|
51
|
+
* @param {string} [opts.initialDir] relative dir to open on boot
|
|
52
|
+
*/
|
|
53
|
+
export function nora({ root, initialDir = null }) {
|
|
54
|
+
/** @type {string|null} relative path of the folder currently being previewed */
|
|
55
|
+
/**
|
|
56
|
+
* The folder being previewed. Server-wide, not per client, and that is a
|
|
57
|
+
* property rather than an oversight: the registry is a single Vite virtual
|
|
58
|
+
* module, and the module graph is keyed by id for the whole server, so there
|
|
59
|
+
* is no version of this that two clients could hold differently.
|
|
60
|
+
*
|
|
61
|
+
* What follows from it is that every open tab must agree. When this changes,
|
|
62
|
+
* every client's registry is stale at once, so the scan endpoint below tells
|
|
63
|
+
* all of them to reload rather than letting whoever reloads last win.
|
|
64
|
+
*/
|
|
65
|
+
let currentDir = initialDir;
|
|
66
|
+
/**
|
|
67
|
+
* @type {{
|
|
68
|
+
* include?: string[],
|
|
69
|
+
* exclude?: string[],
|
|
70
|
+
* defaultDir?: string,
|
|
71
|
+
* entry?: Record<string, string>,
|
|
72
|
+
* }}
|
|
73
|
+
*/
|
|
74
|
+
let userConfig = {};
|
|
75
|
+
|
|
76
|
+
const configFile = findConfigFile(root);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
name: "nora",
|
|
80
|
+
|
|
81
|
+
resolveId(id) {
|
|
82
|
+
if (id === VIRTUAL_ID) return RESOLVED_ID;
|
|
83
|
+
return null;
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
async load(id) {
|
|
87
|
+
if (id !== RESOLVED_ID) return null;
|
|
88
|
+
|
|
89
|
+
const configImport = configFile
|
|
90
|
+
? `import __userConfig from ${JSON.stringify(
|
|
91
|
+
"/" + path.relative(root, configFile).split(path.sep).join("/"),
|
|
92
|
+
)};`
|
|
93
|
+
: "const __userConfig = {};";
|
|
94
|
+
|
|
95
|
+
if (!currentDir) {
|
|
96
|
+
return [
|
|
97
|
+
configImport,
|
|
98
|
+
"export const entries = [];",
|
|
99
|
+
"export const currentDir = null;",
|
|
100
|
+
"export const entryId = null;",
|
|
101
|
+
"export const config = __userConfig;",
|
|
102
|
+
].join("\n");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const { entries, entryId } = await scanDirectory({
|
|
106
|
+
root,
|
|
107
|
+
dir: path.join(root, currentDir),
|
|
108
|
+
include: userConfig.include,
|
|
109
|
+
exclude: userConfig.exclude,
|
|
110
|
+
entry: userConfig.entry?.[currentDir],
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// One explicit dynamic import per entry. A bare import.meta.glob here
|
|
114
|
+
// would only give us file paths — generating the imports ourselves is
|
|
115
|
+
// what lets the palette list every export by name before anything loads.
|
|
116
|
+
const body = entries
|
|
117
|
+
.map((e) => {
|
|
118
|
+
const meta = JSON.stringify(clientEntry(e));
|
|
119
|
+
return ` { ...${meta}, load: () => import(${JSON.stringify(e.url)}) },`;
|
|
120
|
+
})
|
|
121
|
+
.join("\n");
|
|
122
|
+
|
|
123
|
+
return [
|
|
124
|
+
configImport,
|
|
125
|
+
"export const entries = [",
|
|
126
|
+
body,
|
|
127
|
+
"];",
|
|
128
|
+
`export const currentDir = ${JSON.stringify(currentDir)};`,
|
|
129
|
+
`export const entryId = ${JSON.stringify(entryId)};`,
|
|
130
|
+
"export const config = __userConfig;",
|
|
131
|
+
].join("\n");
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
configureServer(server) {
|
|
135
|
+
// Load the user config in Node too, so include/exclude/defaultDir can
|
|
136
|
+
// shape the scan. The client imports the same file separately for the
|
|
137
|
+
// `wrapper` component, which only exists in the browser.
|
|
138
|
+
const loadUserConfig = async () => {
|
|
139
|
+
if (!configFile) return;
|
|
140
|
+
try {
|
|
141
|
+
const mod = await server.ssrLoadModule(configFile);
|
|
142
|
+
userConfig = mod.default ?? {};
|
|
143
|
+
if (!currentDir && userConfig.defaultDir) {
|
|
144
|
+
currentDir = userConfig.defaultDir;
|
|
145
|
+
}
|
|
146
|
+
} catch (err) {
|
|
147
|
+
server.config.logger.warn(
|
|
148
|
+
`[nora] could not load ${path.basename(configFile)}: ${err.message}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
const configReady = loadUserConfig();
|
|
153
|
+
|
|
154
|
+
const guard = (handler) => async (req, res, next) => {
|
|
155
|
+
if (!isLoopbackHost(req)) {
|
|
156
|
+
return sendJson(res, 403, { error: "Non-loopback Host header refused" });
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
await configReady;
|
|
160
|
+
await handler(req, res, next);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
sendJson(res, 400, { error: String(err.message ?? err) });
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// Browse folders. `path` is untrusted and goes through safeResolve.
|
|
167
|
+
server.middlewares.use(
|
|
168
|
+
"/__nora/dirs",
|
|
169
|
+
guard(async (req, res) => {
|
|
170
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
171
|
+
const rel = url.searchParams.get("path") ?? "";
|
|
172
|
+
const abs = safeResolve(root, rel);
|
|
173
|
+
const dirs = await listDirectories(abs);
|
|
174
|
+
const relNorm = path.relative(root, abs).split(path.sep).join("/");
|
|
175
|
+
// No `parent`: the picker needs it synchronously to draw its up row,
|
|
176
|
+
// and derives it from the path it already holds with exactly this
|
|
177
|
+
// logic. Sending it only made the endpoint wider.
|
|
178
|
+
sendJson(res, 200, { path: relNorm, dirs });
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
// Point the previewer at a folder: re-scan, invalidate, reload.
|
|
183
|
+
server.middlewares.use(
|
|
184
|
+
"/__nora/scan",
|
|
185
|
+
guard(async (req, res) => {
|
|
186
|
+
const body = await readJson(req);
|
|
187
|
+
const abs = safeResolve(root, body.path ?? "");
|
|
188
|
+
currentDir = path.relative(root, abs).split(path.sep).join("/");
|
|
189
|
+
|
|
190
|
+
const mod = server.moduleGraph.getModuleById(RESOLVED_ID);
|
|
191
|
+
if (mod) server.moduleGraph.invalidateModule(mod);
|
|
192
|
+
|
|
193
|
+
// Invalidating drops the cached module; it does not tell anyone. The
|
|
194
|
+
// client that asked for this is about to reload itself, but every
|
|
195
|
+
// other open tab is now listing components from a folder this server
|
|
196
|
+
// has stopped serving, and would go on doing so until something else
|
|
197
|
+
// reloaded it. `server.hot` on Vite 6+, `server.ws` before it.
|
|
198
|
+
(server.hot ?? server.ws)?.send?.({ type: "full-reload", path: "*" });
|
|
199
|
+
|
|
200
|
+
sendJson(res, 200, { currentDir });
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export { VIRTUAL_ID, RESOLVED_ID };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve a caller-supplied path against the project root and refuse anything
|
|
5
|
+
* that escapes it.
|
|
6
|
+
*
|
|
7
|
+
* Both `/__nora/dirs` and `/__nora/scan` take a path straight off an HTTP request
|
|
8
|
+
* and hand it to `fs`. Without this clamp, `?path=../../../../etc` walks the
|
|
9
|
+
* whole disk of whoever is running the CLI.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} root absolute project root
|
|
12
|
+
* @param {string} [p] untrusted relative path
|
|
13
|
+
* @returns {string} absolute path guaranteed to sit inside root
|
|
14
|
+
*/
|
|
15
|
+
export function safeResolve(root, p) {
|
|
16
|
+
const abs = path.resolve(root, p ?? ".");
|
|
17
|
+
const rel = path.relative(root, abs);
|
|
18
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
19
|
+
throw new Error(`Path escapes project root: ${p}`);
|
|
20
|
+
}
|
|
21
|
+
return abs;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Reject requests whose Host header is not a loopback address.
|
|
26
|
+
*
|
|
27
|
+
* A dev server bound to localhost is still reachable from any page in the
|
|
28
|
+
* user's browser via DNS rebinding — an attacker's site resolves their domain
|
|
29
|
+
* to 127.0.0.1 and then talks to us with their own Host header. Vite has
|
|
30
|
+
* shipped advisories for exactly this shape of bug.
|
|
31
|
+
*
|
|
32
|
+
* @param {import('node:http').IncomingMessage} req
|
|
33
|
+
* @returns {boolean}
|
|
34
|
+
*/
|
|
35
|
+
export function isLoopbackHost(req) {
|
|
36
|
+
const host = req.headers.host;
|
|
37
|
+
if (!host) return false;
|
|
38
|
+
const name = host.replace(/:\d+$/, "").replace(/^\[|\]$/g, "");
|
|
39
|
+
return (
|
|
40
|
+
name === "localhost" || name === "127.0.0.1" || name === "::1" || name.endsWith(".localhost")
|
|
41
|
+
);
|
|
42
|
+
}
|