@lerret/cli 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 (38) hide show
  1. package/LICENSE +21 -0
  2. package/dist-studio/.bundle-stamp +34 -0
  3. package/dist-studio/assets/asset-runtime-MFjDKvQD.js +129 -0
  4. package/dist-studio/assets/cli-project-source-9dNA_gVa.js +1 -0
  5. package/dist-studio/assets/dev-harness-BH6a8T7l.js +18 -0
  6. package/dist-studio/assets/hosted-project-source-dVGq_8c6.js +135 -0
  7. package/dist-studio/assets/index-BNmJ8c2t.css +1 -0
  8. package/dist-studio/assets/index-EslqdOhg.js +10 -0
  9. package/dist-studio/assets/leaf-marker-command.png +0 -0
  10. package/dist-studio/assets/leaf-marker-comment-box.png +0 -0
  11. package/dist-studio/assets/leaf-marker-homescreen.png +0 -0
  12. package/dist-studio/assets/leafmarker-icon-dark-128.png +0 -0
  13. package/dist-studio/assets/leafmarker-logo-transparent.png +0 -0
  14. package/dist-studio/assets/leafmarker-logo.png +0 -0
  15. package/dist-studio/assets/lerret-logo.png +0 -0
  16. package/dist-studio/assets/lerret-wordmark.svg +3 -0
  17. package/dist-studio/assets/logo-angular.svg +1 -0
  18. package/dist-studio/assets/logo-claude.svg +7 -0
  19. package/dist-studio/assets/logo-codex.svg +1 -0
  20. package/dist-studio/assets/logo-cursor.svg +1 -0
  21. package/dist-studio/assets/logo-javascript.svg +1 -0
  22. package/dist-studio/assets/logo-react.svg +1 -0
  23. package/dist-studio/assets/logo-svelte.svg +1 -0
  24. package/dist-studio/assets/logo-vue.svg +1 -0
  25. package/dist-studio/assets/open-folder-D5OR7eLb.js +8 -0
  26. package/dist-studio/assets/project-studio-BjNaIuRb.js +795 -0
  27. package/dist-studio/assets/project-studio-CKuMOMsC.css +1 -0
  28. package/dist-studio/assets/superwhisper-logo.png +0 -0
  29. package/dist-studio/index.html +47 -0
  30. package/dist-studio/module-sw.js +275 -0
  31. package/package.json +51 -0
  32. package/src/dev.js +373 -0
  33. package/src/export.js +1386 -0
  34. package/src/fs/node-backend.js +631 -0
  35. package/src/lerret.js +143 -0
  36. package/src/resolve-project.js +178 -0
  37. package/src/vite-plugin-lerret-project.js +986 -0
  38. package/src/watcher.js +214 -0
package/src/watcher.js ADDED
@@ -0,0 +1,214 @@
1
+ // watcher.js — the CLI-mode file watcher that turns native `fs` events under
2
+ // a project's `.lerret/` tree into the architecture's normalized
3
+ // {@link WatchEvent}s (AR5).
4
+ //
5
+ // The Node `fs.watch` API on its own is famously coarse and platform-
6
+ // dependent — it does not reliably distinguish `add` / `change` / `remove`,
7
+ // and on macOS its `recursive` mode is half-implemented. So this watcher
8
+ // wraps **chokidar**, the de-facto standard wrapper over `fs.watch` (the same
9
+ // library Vite itself uses). Chokidar gives us per-platform `add` / `change`
10
+ // / `unlink` events and the `addDir` / `unlinkDir` pair for folders, with
11
+ // known/consistent behavior on macOS, Linux, and Windows. We translate that
12
+ // straight into the model's normalized `{ type, path }` shape.
13
+ //
14
+ // THIS file is the only place in `@lerret/cli` allowed to import `chokidar`.
15
+ // `core` stays environment-agnostic (no Node built-ins); the loader's pure
16
+ // `applyWatchEvent` patches the project model off the events this file emits.
17
+ //
18
+ // Path discipline: every emitted `WatchEvent.path` is a {@link LerretPath} —
19
+ // forward-slash, no trailing slash. Chokidar may report native separators on
20
+ // Windows (it normalizes most of the time but not always), so this file does
21
+ // the conversion at the boundary, exactly like the Node fs backend.
22
+
23
+ import { sep as nativeSep } from 'node:path';
24
+
25
+ import chokidar from 'chokidar';
26
+
27
+ import { makeWatchEvent, watchEventType } from '@lerret/core';
28
+
29
+ /**
30
+ * @typedef {import('@lerret/core').WatchEvent} WatchEvent
31
+ * @typedef {import('@lerret/core').LerretPath} LerretPath
32
+ */
33
+
34
+ /**
35
+ * Convert a native OS path into a contract-level {@link LerretPath}
36
+ * (forward slashes, no trailing slash).
37
+ *
38
+ * @param {string} nativePath
39
+ * @returns {string}
40
+ */
41
+ function toLerretPath(nativePath) {
42
+ const slashed = nativeSep === '/' ? nativePath : nativePath.replaceAll(nativeSep, '/');
43
+ // Strip a trailing slash so `/a/b/` and `/a/b` are the same path. A bare
44
+ // root `'/'` is left alone — that case never reaches the watcher (the scan
45
+ // root is always a `.lerret/` directory).
46
+ return slashed.length > 1 ? slashed.replace(/\/+$/, '') : slashed;
47
+ }
48
+
49
+ /**
50
+ * Convert a contract-level {@link LerretPath} into a path the host OS
51
+ * understands — used when handing the scan root to chokidar.
52
+ *
53
+ * @param {string} lerretPath
54
+ * @returns {string}
55
+ */
56
+ function toNativePath(lerretPath) {
57
+ return nativeSep === '/' ? lerretPath : lerretPath.replaceAll('/', nativeSep);
58
+ }
59
+
60
+ /**
61
+ * Chokidar event names → normalized `WatchEventType`. Folder add/remove
62
+ * (`addDir` / `unlinkDir`) and file add/remove map onto the same `add`/
63
+ * `remove` semantics — the loader's patcher uses path classification to
64
+ * decide whether the event is for a page, group, asset, or font.
65
+ *
66
+ * @type {Readonly<Record<string, import('@lerret/core').watchEventType[keyof typeof import('@lerret/core').watchEventType]>>}
67
+ */
68
+ const CHOKIDAR_TO_TYPE = Object.freeze({
69
+ add: watchEventType.ADD,
70
+ addDir: watchEventType.ADD,
71
+ change: watchEventType.CHANGE,
72
+ unlink: watchEventType.REMOVE,
73
+ unlinkDir: watchEventType.REMOVE,
74
+ });
75
+
76
+ /**
77
+ * Optional handler invoked when chokidar's underlying watch errors. Stays
78
+ * non-fatal — the watcher logs and keeps running rather than tearing down
79
+ * the live-edit loop on a transient `fs` error.
80
+ *
81
+ * @callback WatcherErrorHandler
82
+ * @param {Error} err
83
+ * @returns {void}
84
+ */
85
+
86
+ /**
87
+ * The handle returned by {@link startWatcher}. Calling `close()` stops the
88
+ * underlying chokidar watcher and releases the OS resources. Idempotent.
89
+ *
90
+ * @typedef {object} WatcherHandle
91
+ * @property {() => Promise<void>} close
92
+ * Stop watching. Resolves once chokidar has fully closed. Safe to call
93
+ * more than once.
94
+ * @property {Promise<void>} ready
95
+ * Resolves once chokidar has done its initial scan and reported `'ready'`
96
+ * — the watcher is now genuinely live. The `ignoreInitial: true` option
97
+ * means no `add` events fire for files that already existed at start;
98
+ * `await handle.ready` lets a caller (or a test) wait for that quiescent
99
+ * point before triggering edits.
100
+ */
101
+
102
+ /**
103
+ * Begin watching a project's `.lerret/` directory for changes, emitting one
104
+ * normalized {@link WatchEvent} per filesystem change.
105
+ *
106
+ * Configuration is deliberately conservative:
107
+ *
108
+ * - `ignoreInitial: true` — chokidar does not emit `add` for files that
109
+ * already existed at start. The initial project state is the loader's job
110
+ * (`scan`), not the watcher's; the watcher only reports CHANGES from that
111
+ * baseline. (Without this we would re-feed every file at boot as `add`,
112
+ * doubling the work of the initial scan.)
113
+ * - `awaitWriteFinish` — debounces a save so the editor's "write the new
114
+ * bytes, then truncate, then close" sequence (which can fire multiple
115
+ * `change` events) yields exactly one. The values are short enough to
116
+ * stay well inside the 1-second NFR2 budget while still coalescing.
117
+ * - No `ignored` patterns by default — the loader's path classification
118
+ * filters out the irrelevant paths (config.json, images, anything under a
119
+ * reserved folder). Watching everything keeps the watcher dumb and the
120
+ * model the single source of mapping rules.
121
+ *
122
+ * @param {object} opts
123
+ * @param {LerretPath} opts.root
124
+ * The project's scan root — the `.lerret/` directory. Same path the
125
+ * loader scanned.
126
+ * @param {(event: WatchEvent) => void} opts.onEvent
127
+ * Called once per normalized event. Receives the validated `WatchEvent`;
128
+ * callers feed it straight to `applyWatchEvent` and re-render off the new
129
+ * model.
130
+ * @param {WatcherErrorHandler} [opts.onError]
131
+ * Optional non-fatal error handler. Defaults to `console.error`.
132
+ * @param {object} [opts.options]
133
+ * Pass-through overrides for chokidar's option bag — exposed for tests
134
+ * that need to tune timing. Merged on top of the defaults.
135
+ * @returns {WatcherHandle}
136
+ */
137
+ export function startWatcher({ root, onEvent, onError, options = {} }) {
138
+ if (typeof root !== 'string' || root.length === 0) {
139
+ throw new TypeError('startWatcher: root must be a non-empty LerretPath string');
140
+ }
141
+ if (typeof onEvent !== 'function') {
142
+ throw new TypeError('startWatcher: onEvent must be a function');
143
+ }
144
+ const errorHandler =
145
+ typeof onError === 'function' ? onError : (err) => console.error('[watcher]', err);
146
+
147
+ // Chokidar takes a glob-like or path string; we pass the native form so it
148
+ // does its own native-`fs` work without re-translating.
149
+ const watcher = chokidar.watch(toNativePath(root), {
150
+ // Don't fire `add` events for the initial state — the loader already
151
+ // built the model from `scan()`.
152
+ ignoreInitial: true,
153
+ // Coalesce a save's multiple writes into one `change` event.
154
+ awaitWriteFinish: {
155
+ stabilityThreshold: 80,
156
+ pollInterval: 20,
157
+ },
158
+ // Keep symlink quirkiness out of the live-edit loop — match the loader,
159
+ // which never follows symlinks either.
160
+ followSymlinks: false,
161
+ // Always include subdirectories; the watcher is for the whole `.lerret/`.
162
+ depth: undefined,
163
+ // Chokidar normally pre-loads stats. Disabling it shortens initial start
164
+ // on bigger projects; the per-event payload doesn't depend on stat info.
165
+ alwaysStat: false,
166
+ ...options,
167
+ });
168
+
169
+ // `ready` resolves once chokidar has done its initial silent walk. Tests
170
+ // await this before triggering edits so the watcher is genuinely live.
171
+ /** @type {(value: void) => void} */
172
+ let resolveReady;
173
+ /** @type {(reason: unknown) => void} */
174
+ let rejectReady;
175
+ const ready = new Promise((resolve, reject) => {
176
+ resolveReady = resolve;
177
+ rejectReady = reject;
178
+ });
179
+
180
+ // Forward each chokidar event as a normalized WatchEvent.
181
+ watcher.on('all', (chokidarEvent, nativePath) => {
182
+ const type = CHOKIDAR_TO_TYPE[chokidarEvent];
183
+ if (type === undefined) return; // 'ready' / 'error' / 'raw' — not change events
184
+ if (typeof nativePath !== 'string' || nativePath.length === 0) return;
185
+ try {
186
+ // `makeWatchEvent` validates + normalizes — a single place to enforce
187
+ // the contract. A bug in mapping fails loudly here, not silently in a
188
+ // consumer.
189
+ onEvent(makeWatchEvent(type, toLerretPath(nativePath)));
190
+ } catch (err) {
191
+ errorHandler(/** @type {Error} */ (err));
192
+ }
193
+ });
194
+
195
+ watcher.on('ready', () => resolveReady());
196
+ watcher.on('error', (err) => {
197
+ errorHandler(/** @type {Error} */ (err));
198
+ // A pre-`ready` error means the watcher never came up — reject so the
199
+ // caller's `await handle.ready` does not hang forever.
200
+ rejectReady(err);
201
+ });
202
+
203
+ let closed = false;
204
+ return {
205
+ ready,
206
+ async close() {
207
+ if (closed) return;
208
+ closed = true;
209
+ await watcher.close();
210
+ },
211
+ };
212
+ }
213
+
214
+ export { CHOKIDAR_TO_TYPE };