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,107 @@
|
|
|
1
|
+
/* =========================================================================
|
|
2
|
+
Styles for the iframe document only.
|
|
3
|
+
|
|
4
|
+
Deliberately minimal: this document holds the user's component, so the less
|
|
5
|
+
we impose the closer the preview is to their real app. No box-sizing reset,
|
|
6
|
+
no inherited font, no margin collapsing tricks — only what the error surface
|
|
7
|
+
needs, all namespaced under .noraf-.
|
|
8
|
+
|
|
9
|
+
The bundled faces are imported here too, because an error card is *our*
|
|
10
|
+
chrome even though it renders in the user's document, and it looked
|
|
11
|
+
borrowed while the bar beside it did not. Nothing inherits them: the
|
|
12
|
+
@font-face rules are inert until one of the .noraf- rules below asks for them,
|
|
13
|
+
so a component that renders fine never decodes a byte. And the families are
|
|
14
|
+
namespaced ("Nora Geist"), so a project that loads the real Geist into its own
|
|
15
|
+
components is untouched by ours.
|
|
16
|
+
========================================================================= */
|
|
17
|
+
|
|
18
|
+
@import "./fonts.css";
|
|
19
|
+
|
|
20
|
+
:root {
|
|
21
|
+
--noraf-bg: #f1f2f4;
|
|
22
|
+
--noraf-ink: #14171c;
|
|
23
|
+
--noraf-muted: #5c6672;
|
|
24
|
+
--noraf-line: #d8dce1;
|
|
25
|
+
--noraf-danger: #a32f2f;
|
|
26
|
+
--noraf-danger-bg: #fbeeee;
|
|
27
|
+
--noraf-mono: "Nora Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
28
|
+
--noraf-sans:
|
|
29
|
+
"Nora Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
:root[data-nora-theme="dark"] {
|
|
33
|
+
--noraf-bg: #101317;
|
|
34
|
+
--noraf-ink: #e6e9ed;
|
|
35
|
+
--noraf-muted: #8b95a2;
|
|
36
|
+
--noraf-line: #262d36;
|
|
37
|
+
--noraf-danger: #e07575;
|
|
38
|
+
--noraf-danger-bg: #2a1818;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
html,
|
|
42
|
+
body {
|
|
43
|
+
margin: 0;
|
|
44
|
+
min-height: 100%;
|
|
45
|
+
background: var(--noraf-bg);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#nora-frame-root {
|
|
49
|
+
min-height: 100vh;
|
|
50
|
+
display: flex;
|
|
51
|
+
align-items: center;
|
|
52
|
+
justify-content: center;
|
|
53
|
+
padding: 1.5rem;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* The stage itself adds nothing — no padding, no background — so a component
|
|
57
|
+
with its own margins behaves exactly as it does in the real app. */
|
|
58
|
+
.noraf-stage {
|
|
59
|
+
max-width: 100%;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.noraf-loading {
|
|
63
|
+
font-family: var(--noraf-mono);
|
|
64
|
+
font-size: 0.78rem;
|
|
65
|
+
color: var(--noraf-muted);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.noraf-error {
|
|
69
|
+
box-sizing: border-box;
|
|
70
|
+
max-width: 60ch;
|
|
71
|
+
border: 1px solid var(--noraf-danger);
|
|
72
|
+
background: var(--noraf-danger-bg);
|
|
73
|
+
border-radius: 8px;
|
|
74
|
+
padding: 1rem 1.15rem;
|
|
75
|
+
display: flex;
|
|
76
|
+
flex-direction: column;
|
|
77
|
+
gap: 0.6rem;
|
|
78
|
+
font-family: var(--noraf-sans);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.noraf-error-head {
|
|
82
|
+
font-family: var(--noraf-sans);
|
|
83
|
+
font-size: 0.86rem;
|
|
84
|
+
letter-spacing: -0.01em;
|
|
85
|
+
font-weight: 600;
|
|
86
|
+
color: var(--noraf-danger);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.noraf-error-msg {
|
|
90
|
+
margin: 0;
|
|
91
|
+
font-family: var(--noraf-mono);
|
|
92
|
+
font-size: 0.75rem;
|
|
93
|
+
line-height: 1.6;
|
|
94
|
+
color: var(--noraf-ink);
|
|
95
|
+
white-space: pre-wrap;
|
|
96
|
+
word-break: break-word;
|
|
97
|
+
max-height: 12rem;
|
|
98
|
+
overflow: auto;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.noraf-error-hint {
|
|
102
|
+
font-size: 0.84rem;
|
|
103
|
+
line-height: 1.55;
|
|
104
|
+
color: var(--noraf-muted);
|
|
105
|
+
border-top: 1px dashed var(--noraf-line);
|
|
106
|
+
padding-top: 0.6rem;
|
|
107
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { entries, config } from "virtual:nora/registry";
|
|
4
|
+
import { Preview } from "./canvas/Preview.jsx";
|
|
5
|
+
import { matchChord } from "./chords.js";
|
|
6
|
+
import "./frame.css";
|
|
7
|
+
|
|
8
|
+
const CHANNEL = "nora";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The document inside the iframe. It holds the whole render path — the config
|
|
12
|
+
* wrapper, the error boundary, and the component — so that everything the
|
|
13
|
+
* component can observe about its environment (viewport width, media queries,
|
|
14
|
+
* `vw` units, `window.innerWidth`) is the size the toolbar asked for.
|
|
15
|
+
*
|
|
16
|
+
* The shell talks to it over postMessage rather than by swapping the iframe
|
|
17
|
+
* src, so switching components does not tear down and rebuild the document.
|
|
18
|
+
*/
|
|
19
|
+
function Frame() {
|
|
20
|
+
const [selectedId, setSelectedId] = useState(null);
|
|
21
|
+
const [theme, setTheme] = useState("light");
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const onMessage = (event) => {
|
|
25
|
+
if (event.origin !== location.origin) return;
|
|
26
|
+
const msg = event.data;
|
|
27
|
+
if (!msg || msg.source !== CHANNEL) return;
|
|
28
|
+
if (msg.type === "select") {
|
|
29
|
+
setSelectedId(msg.id ?? null);
|
|
30
|
+
if (msg.theme) setTheme(msg.theme);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
window.addEventListener("message", onMessage);
|
|
34
|
+
|
|
35
|
+
// Clicking anything in the preview moves focus into this iframe, and from
|
|
36
|
+
// then on every keystroke lands here instead of on the shell — so ⌘K and
|
|
37
|
+
// friends silently stop working the moment you interact with the component
|
|
38
|
+
// you are inspecting. Forward the shortcuts we own back up.
|
|
39
|
+
//
|
|
40
|
+
// Deliberately narrow: only our own chords. Plain keys stay in the frame so
|
|
41
|
+
// a previewed text field, slider, or menu still behaves normally.
|
|
42
|
+
const onKey = (event) => {
|
|
43
|
+
const chord = matchChord(event);
|
|
44
|
+
if (!chord) return;
|
|
45
|
+
event.preventDefault();
|
|
46
|
+
window.parent?.postMessage({ source: CHANNEL, type: "shortcut", chord }, location.origin);
|
|
47
|
+
};
|
|
48
|
+
window.addEventListener("keydown", onKey);
|
|
49
|
+
|
|
50
|
+
// Tell the shell we are mounted and ready to be told what to render.
|
|
51
|
+
// The shell also posts on iframe load, so this is belt and braces for HMR.
|
|
52
|
+
window.parent?.postMessage({ source: CHANNEL, type: "ready" }, location.origin);
|
|
53
|
+
|
|
54
|
+
return () => {
|
|
55
|
+
window.removeEventListener("message", onMessage);
|
|
56
|
+
window.removeEventListener("keydown", onKey);
|
|
57
|
+
};
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
60
|
+
// The one thing a previewed component can rely on: the active theme, named on
|
|
61
|
+
// the root of the document it renders in. frame.css keys its own dark palette
|
|
62
|
+
// off the same attribute, and so can yours.
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
document.documentElement.dataset.noraTheme = theme;
|
|
65
|
+
}, [theme]);
|
|
66
|
+
|
|
67
|
+
const entry = entries.find((e) => e.id === selectedId) ?? null;
|
|
68
|
+
if (!entry) return null;
|
|
69
|
+
|
|
70
|
+
return <Preview entry={entry} wrapper={config?.wrapper} />;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
createRoot(document.getElementById("nora-frame-root")).render(<Frame />);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>nora</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="nora-root"></div>
|
|
10
|
+
<script type="module" src="__CP_ENTRY__"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { writeSelectionToUrl } from "./selection.js";
|
|
2
|
+
|
|
3
|
+
const REOPEN_KEY = "nora:reopen-picker";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Point nora at a folder.
|
|
7
|
+
*
|
|
8
|
+
* This used to be half an operation in two places: the picker ran the fetch and
|
|
9
|
+
* handed back the folder the server confirmed, and the shell reloaded without
|
|
10
|
+
* accepting it. The value crossed a seam and was dropped on the other side.
|
|
11
|
+
*
|
|
12
|
+
* One module owns the whole thing now, and it contains no React, so it can be
|
|
13
|
+
* exercised without a renderer.
|
|
14
|
+
*
|
|
15
|
+
* **It does not return on success.** Changing folders rebuilds the registry on
|
|
16
|
+
* the server, which ends this document rather than updating it, so the only
|
|
17
|
+
* outcomes are "the page is being replaced" and "it threw". Callers should
|
|
18
|
+
* treat a rejection as the sole case worth handling, and leave any pending
|
|
19
|
+
* state set: there is no render after this to clear it in.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} dir folder to scan, relative to the project root
|
|
22
|
+
* @returns {Promise<never>}
|
|
23
|
+
*/
|
|
24
|
+
export async function openFolder(dir) {
|
|
25
|
+
// Raised before the request, not after. A successful scan makes the server
|
|
26
|
+
// tell every client to reload, and that broadcast can reach this one while it
|
|
27
|
+
// is still awaiting the response — so the flag has to already be set, or the
|
|
28
|
+
// picker would vanish on the way through.
|
|
29
|
+
setFlag(true);
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch("/__nora/scan", {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: { "Content-Type": "application/json" },
|
|
35
|
+
body: JSON.stringify({ path: dir }),
|
|
36
|
+
});
|
|
37
|
+
const json = await res.json();
|
|
38
|
+
if (json?.error) throw new Error(json.error);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
setFlag(false); // no reload is coming; don't leave it armed for the next one
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The selection belonged to the folder we are leaving.
|
|
45
|
+
writeSelectionToUrl(null);
|
|
46
|
+
|
|
47
|
+
location.reload();
|
|
48
|
+
return await new Promise(() => {});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @param {boolean} on */
|
|
52
|
+
function setFlag(on) {
|
|
53
|
+
try {
|
|
54
|
+
if (on) sessionStorage.setItem(REOPEN_KEY, "1");
|
|
55
|
+
else sessionStorage.removeItem(REOPEN_KEY);
|
|
56
|
+
} catch {
|
|
57
|
+
/* storage disabled — the picker just won't reopen by itself */
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Did this document begin with someone opening a folder?
|
|
63
|
+
*
|
|
64
|
+
* The flag is the only thing that survives the reload above, and it is what
|
|
65
|
+
* keeps that reload from being felt: the picker was open before and is open
|
|
66
|
+
* after, now listing the folder just opened.
|
|
67
|
+
*
|
|
68
|
+
* Consumed on read, so call it exactly once, at module scope. A second call
|
|
69
|
+
* always misses, and a call inside a component would miss on every render after
|
|
70
|
+
* the first.
|
|
71
|
+
*
|
|
72
|
+
* @returns {boolean}
|
|
73
|
+
*/
|
|
74
|
+
export function consumeReopenFlag() {
|
|
75
|
+
try {
|
|
76
|
+
if (sessionStorage.getItem(REOPEN_KEY) === "1") {
|
|
77
|
+
sessionStorage.removeItem(REOPEN_KEY);
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
/* storage disabled — the handoff just doesn't happen */
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The selected component lives in the URL, so a reload, an HMR update and a
|
|
3
|
+
* pasted link all keep your place.
|
|
4
|
+
*
|
|
5
|
+
* Its own module because two things need it and they are not in the same tree:
|
|
6
|
+
* the shell reads it on boot, and opening a folder clears it on the way out
|
|
7
|
+
* (see open-folder.js). Duplicating four lines of URLSearchParams across both
|
|
8
|
+
* is how the two drift.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** @returns {string | null} */
|
|
12
|
+
export function readSelectionFromUrl() {
|
|
13
|
+
return new URLSearchParams(location.search).get("c");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** @param {string | null} id */
|
|
17
|
+
export function writeSelectionToUrl(id) {
|
|
18
|
+
const url = new URL(location.href);
|
|
19
|
+
if (id) url.searchParams.set("c", id);
|
|
20
|
+
else url.searchParams.delete("c");
|
|
21
|
+
history.replaceState(null, "", url);
|
|
22
|
+
}
|