@deskwork/studio 0.9.5 → 0.9.6

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.
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Scrapbook viewer — `/dev/scrapbook/:site/<path>` (path may include `/`).
3
+ *
4
+ * Reads the scrapbook directory at the given path and lists every
5
+ * file with type chips + relative timestamps, plus secret items
6
+ * (inside `scrapbook/secret/`) in a quiet second section. Empty
7
+ * scrapbooks render an empty state with quick-add affordances.
8
+ *
9
+ * The `path` argument is the hierarchical address of the scrapbook —
10
+ * any slash-separated kebab-case identifier under the site's
11
+ * contentDir. It does not need to correspond to a calendar entry;
12
+ * organizational nodes (e.g. `the-outbound/characters` with no
13
+ * own README) can host their own scrapbooks too.
14
+ *
15
+ * Port of `pages/dev/scrapbook/[site]/[slug].astro`. Layout swap
16
+ * (Astro `<Layout>` → studio shell) and CSS link added; structurally
17
+ * similar otherwise.
18
+ */
19
+
20
+ import {
21
+ formatRelativeTime,
22
+ formatSize,
23
+ listScrapbook,
24
+ type ScrapbookItem,
25
+ } from '@deskwork/core/scrapbook';
26
+ import type { StudioContext } from '../routes/api.ts';
27
+ import { html, unsafe, type RawHtml } from './html.ts';
28
+ import { layout } from './layout.ts';
29
+ import { renderEditorialFolio } from './chrome.ts';
30
+
31
+ interface RenderItemRowOptions {
32
+ /** Mark the row visually as belonging to the secret section. */
33
+ secret?: boolean;
34
+ /**
35
+ * When true, render disclosure controls + toolbar. Both public AND
36
+ * secret rows now ship the toolbar (#28); the per-section tools
37
+ * include a "Mark secret" / "Mark public" toggle that the client
38
+ * resolves into a cross-section rename. Pre-#28 secret rows were
39
+ * read-only — that decision is reversed here so operators have full
40
+ * CRUD over secret/ items from the standalone viewer.
41
+ */
42
+ withTools?: boolean;
43
+ }
44
+
45
+ function renderItemRow(
46
+ item: ScrapbookItem,
47
+ index: number,
48
+ opts: RenderItemRowOptions = {},
49
+ ): RawHtml {
50
+ const { secret = false, withTools = true } = opts;
51
+ const editBtn =
52
+ withTools && item.kind === 'md'
53
+ ? unsafe(html`<button type="button" class="scrapbook-tool" data-action="edit">edit</button>`)
54
+ : '';
55
+ const seq = String(index + 1).padStart(2, '0');
56
+ const kindLabel = item.kind === 'other' ? '·' : item.kind.toUpperCase();
57
+ const idPrefix = secret ? 'secret-' : '';
58
+ const dataSecret = secret ? ' data-secret="true"' : '';
59
+ // The "mark secret/public" toggle is the cross-section rename
60
+ // affordance. The button label flips with the source section.
61
+ const sectionToggleLabel = secret ? 'mark public' : 'mark secret';
62
+ const toolbar = withTools
63
+ ? unsafe(html`<div class="scrapbook-toolbar" data-toolbar>
64
+ ${editBtn}
65
+ <button type="button" class="scrapbook-tool" data-action="rename">rename</button>
66
+ <button type="button" class="scrapbook-tool" data-action="toggle-secret">${sectionToggleLabel}</button>
67
+ <button type="button" class="scrapbook-tool scrapbook-tool--delete" data-action="delete">delete</button>
68
+ </div>`)
69
+ : '';
70
+ return unsafe(html`
71
+ <li class="scrapbook-item${secret ? ' scrapbook-item--secret' : ''}" data-state="closed" data-open="false"
72
+ data-filename="${item.name}" data-kind="${item.kind}"
73
+ data-size="${item.size}" data-mtime="${item.mtime}"${unsafe(dataSecret)}
74
+ id="${idPrefix}item-${encodeURIComponent(item.name)}">
75
+ <button type="button" class="scrapbook-item-header" aria-expanded="false">
76
+ <span class="scrapbook-seq" aria-hidden="true">§ ${seq}</span>
77
+ <span class="scrapbook-kind scrapbook-kind--${item.kind}" aria-hidden="true">${kindLabel}</span>
78
+ <span class="scrapbook-filename" data-filename-cell>${item.name}</span>
79
+ <time class="scrapbook-mtime" datetime="${item.mtime}">${formatRelativeTime(item.mtime)}</time>
80
+ <span class="scrapbook-disclosure" aria-hidden="true">▸</span>
81
+ </button>
82
+ ${toolbar}
83
+ <div class="scrapbook-perforation" aria-hidden="true"></div>
84
+ <div class="scrapbook-item-body" data-body>
85
+ <div data-body-content></div>
86
+ </div>
87
+ </li>`);
88
+ }
89
+
90
+ /**
91
+ * Build a hierarchical breadcrumb from the path. Each segment links to
92
+ * the scrapbook view for its prefix. The root segment (site) just
93
+ * goes back to the editorial dashboard.
94
+ */
95
+ function renderBreadcrumb(site: string, path: string): RawHtml {
96
+ const segments = path.split('/');
97
+ const links: string[] = [];
98
+ for (let i = 0; i < segments.length; i++) {
99
+ const prefix = segments.slice(0, i + 1).join('/');
100
+ const isLast = i === segments.length - 1;
101
+ if (isLast) {
102
+ links.push(html`<span class="scrapbook-breadcrumb-current">${segments[i]}</span>`);
103
+ } else {
104
+ links.push(
105
+ html`<a class="scrapbook-breadcrumb-link" href="/dev/scrapbook/${site}/${prefix}">${segments[i]}</a>`,
106
+ );
107
+ }
108
+ }
109
+ const sep = '<span class="scrapbook-breadcrumb-sep" aria-hidden="true">›</span>';
110
+ const joined = links.join(`\n${sep}\n`);
111
+ return unsafe(html`
112
+ <nav class="scrapbook-breadcrumb" aria-label="scrapbook hierarchy">
113
+ <a class="scrapbook-breadcrumb-link" href="/dev/editorial-studio">${site}</a>
114
+ <span class="scrapbook-breadcrumb-sep" aria-hidden="true">›</span>
115
+ ${unsafe(joined)}
116
+ </nav>`);
117
+ }
118
+
119
+ function renderIndexSidebar(items: readonly ScrapbookItem[], site: string, path: string): RawHtml {
120
+ const totalBytes = items.reduce((acc, item) => acc + item.size, 0);
121
+ const lastModified =
122
+ items.length > 0
123
+ ? items.reduce((a, b) => (a.mtime > b.mtime ? a : b)).mtime
124
+ : null;
125
+ return unsafe(html`
126
+ <aside class="scrapbook-index">
127
+ <p class="scrapbook-index-kicker">
128
+ <span aria-hidden="true">§</span> The folder
129
+ </p>
130
+ <p class="scrapbook-index-meta">${path}</p>
131
+ <p class="scrapbook-index-meta">${site}</p>
132
+ <hr />
133
+ <ol class="scrapbook-index-list" data-scrapbook-index>
134
+ ${items.map(
135
+ (item, i) => unsafe(html`<li data-index-for="${item.name}">
136
+ <span class="scrapbook-index-num">No. ${String(i + 1).padStart(2, '0')}</span>
137
+ <a href="#item-${encodeURIComponent(item.name)}">${item.name}</a>
138
+ </li>`),
139
+ )}
140
+ </ol>
141
+ <hr />
142
+ <p class="scrapbook-index-totals">${items.length} ${items.length === 1 ? 'item' : 'items'} · ${formatSize(totalBytes)}</p>
143
+ ${
144
+ lastModified
145
+ ? unsafe(html`<p class="scrapbook-index-subtotal">last modified ${formatRelativeTime(lastModified)}</p>`)
146
+ : ''
147
+ }
148
+ <hr />
149
+ <div class="scrapbook-index-actions">
150
+ <button type="button" class="scrapbook-index-btn" data-action="new-note">+ new note</button>
151
+ <button type="button" class="scrapbook-index-btn" data-action="upload">+ upload file</button>
152
+ </div>
153
+ <hr />
154
+ <p class="scrapbook-index-path">${site}/${path}/scrapbook/</p>
155
+ </aside>`);
156
+ }
157
+
158
+ function renderEmpty(): RawHtml {
159
+ return unsafe(html`
160
+ <section class="scrapbook-empty">
161
+ <p>
162
+ This scrapbook is empty. Write the first note, or drop a file
163
+ anywhere on this page.
164
+ </p>
165
+ <div class="scrapbook-empty-actions">
166
+ <button type="button" class="scrapbook-index-btn" data-action="new-note">+ new note</button>
167
+ <button type="button" class="scrapbook-index-btn" data-action="upload">+ upload file</button>
168
+ </div>
169
+ </section>`);
170
+ }
171
+
172
+ function renderReadingPanel(items: readonly ScrapbookItem[]): RawHtml {
173
+ return unsafe(html`
174
+ <section class="scrapbook-reading">
175
+ <form class="scrapbook-composer" data-scrapbook-composer hidden>
176
+ <div class="scrapbook-composer-header">
177
+ <span class="scrapbook-composer-seq" aria-hidden="true">✎</span>
178
+ <span class="scrapbook-composer-kind">NEW</span>
179
+ <input type="text" class="scrapbook-composer-filename" data-composer-filename
180
+ placeholder="note-name.md" aria-label="new note filename" />
181
+ <div class="scrapbook-editor-footer scrapbook-composer-actions">
182
+ <label class="scrapbook-secret-toggle" title="save under scrapbook/secret/ — never published">
183
+ <input type="checkbox" data-composer-secret />
184
+ <span>secret</span>
185
+ </label>
186
+ <button type="button" class="scrapbook-tool" data-action="composer-cancel">cancel</button>
187
+ <button type="submit" class="scrapbook-tool scrapbook-tool--primary" data-action="composer-save">save →</button>
188
+ </div>
189
+ </div>
190
+ <div class="scrapbook-composer-body">
191
+ <textarea data-composer-body
192
+ placeholder="Write the note in markdown. Cmd/Ctrl+S saves."
193
+ aria-label="new note body"></textarea>
194
+ </div>
195
+ </form>
196
+ <ol class="scrapbook-items" data-scrapbook-items>
197
+ ${items.map((item, i) => renderItemRow(item, i))}
198
+ </ol>
199
+ <div class="scrapbook-drop" data-scrapbook-drop role="button" tabindex="0"
200
+ aria-label="upload a file to the scrapbook">
201
+ <span class="scrapbook-drop-label">── drop a file here, or pick one ──</span>
202
+ <input type="file" data-scrapbook-file-input
203
+ accept="image/*,application/json,text/plain,text/markdown,.md,.json,.txt" />
204
+ <label class="scrapbook-secret-toggle scrapbook-secret-toggle--upload"
205
+ title="save the upload under scrapbook/secret/ — never published">
206
+ <input type="checkbox" data-upload-secret />
207
+ <span>upload as secret</span>
208
+ </label>
209
+ </div>
210
+ </section>`);
211
+ }
212
+
213
+ /**
214
+ * Quiet second section listing items inside `scrapbook/secret/`. Read-
215
+ * only in v1 — operators populate the directory by hand or via the
216
+ * core API; the studio surface just shows what's there. The "private"
217
+ * badge gives unmistakable visual differentiation from the public
218
+ * items above.
219
+ */
220
+ function renderSecretSection(items: readonly ScrapbookItem[]): RawHtml {
221
+ return unsafe(html`
222
+ <section class="scrapbook-secret" data-scrapbook-secret>
223
+ <header class="scrapbook-secret-header">
224
+ <span class="scrapbook-secret-mark" aria-hidden="true">⚿</span>
225
+ <h2 class="scrapbook-secret-title">Secret</h2>
226
+ <span class="scrapbook-secret-badge" aria-label="private — never published">
227
+ private
228
+ </span>
229
+ <span class="scrapbook-secret-count">
230
+ ${items.length} ${items.length === 1 ? 'item' : 'items'}
231
+ </span>
232
+ </header>
233
+ <p class="scrapbook-secret-help">
234
+ Items inside <code>scrapbook/secret/</code>. Excluded from the
235
+ public site by the host's content-collection patterns.
236
+ </p>
237
+ <ol class="scrapbook-items scrapbook-items--secret">
238
+ ${items.map((item, i) =>
239
+ renderItemRow(item, i, { secret: true, withTools: true }),
240
+ )}
241
+ </ol>
242
+ </section>`);
243
+ }
244
+
245
+ export function renderScrapbookPage(
246
+ ctx: StudioContext,
247
+ site: string,
248
+ path: string,
249
+ ): string {
250
+ // Validate site against the project's configured site list. Without
251
+ // this check, an unknown site key reaches the path resolver and
252
+ // produces either an opaque error or a path traversal vector.
253
+ if (!(site in ctx.config.sites)) {
254
+ throw new Error(`unknown site: ${site}`);
255
+ }
256
+ const summary = listScrapbook(ctx.projectRoot, ctx.config, site, path);
257
+ const items = summary.items;
258
+ const secretItems = summary.secretItems;
259
+
260
+ const publicBlock =
261
+ items.length === 0
262
+ ? renderEmpty().__raw
263
+ : renderReadingPanel(items).__raw + renderIndexSidebar(items, site, path).__raw;
264
+
265
+ const secretBlock = secretItems.length > 0 ? renderSecretSection(secretItems).__raw : '';
266
+
267
+ const body = html`
268
+ ${renderEditorialFolio('content', `scrapbook · ${site}/${path}`)}
269
+ <main class="scrapbook-page" data-site="${site}" data-slug="${path}" data-scrapbook-root>
270
+ <header class="er-pagehead er-pagehead--compact scrapbook-header">
271
+ ${renderBreadcrumb(site, path)}
272
+ <p class="er-pagehead__kicker scrapbook-kicker">
273
+ <span class="scrapbook-kicker-mark" aria-hidden="true">§</span>
274
+ Scrapbook
275
+ </p>
276
+ <h1 class="er-pagehead__title scrapbook-title">${path}</h1>
277
+ <a class="scrapbook-back" href="/dev/editorial-studio">← back to the desk</a>
278
+ </header>
279
+ <div class="scrapbook-status" data-scrapbook-status hidden></div>
280
+ ${unsafe(publicBlock)}
281
+ ${unsafe(secretBlock)}
282
+ <div class="scrapbook-drop-overlay" data-scrapbook-overlay aria-hidden="true">
283
+ <span class="scrapbook-drop-overlay-text">drop to add to the scrapbook ◇</span>
284
+ </div>
285
+ </main>`;
286
+
287
+ return layout({
288
+ title: `scrapbook · ${path} — dev`,
289
+ cssHrefs: [
290
+ '/static/css/editorial-review.css',
291
+ '/static/css/editorial-nav.css',
292
+ '/static/css/scrapbook.css',
293
+ '/static/css/blog-figure.css',
294
+ ],
295
+ bodyAttrs: 'data-review-ui="studio"',
296
+ bodyHtml: body,
297
+ scriptModules: ['/static/dist/scrapbook-client.js'],
298
+ });
299
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Shortform desk index — `/dev/editorial-review-shortform`.
3
+ *
4
+ * Phase 21c: this page used to render compose textareas + dead Save /
5
+ * Approve / Iterate / Reject buttons that had no handlers. The new
6
+ * design unifies shortform + longform behind one review surface, so
7
+ * the desk becomes a pure navigation index — every open shortform
8
+ * workflow is a link into `/dev/editorial-review/<workflow.id>` where
9
+ * the operator gets the full editor (save / iterate / approve /
10
+ * reject) without a parallel composer to maintain.
11
+ *
12
+ * The folio strip + page chrome stay; the per-card textarea +
13
+ * inline action buttons go away.
14
+ */
15
+
16
+ import { listOpen } from '@deskwork/core/review/pipeline';
17
+ import type { DraftWorkflowItem } from '@deskwork/core/review/types';
18
+ import type { StudioContext } from '../routes/api.ts';
19
+ import { html, unsafe, type RawHtml } from './html.ts';
20
+ import { layout } from './layout.ts';
21
+ import { renderEditorialFolio } from './chrome.ts';
22
+
23
+ const PLATFORM_ORDER = ['reddit', 'linkedin', 'youtube', 'instagram'] as const;
24
+
25
+ function siteLabel(site: string): string {
26
+ return site.slice(0, 2).toUpperCase();
27
+ }
28
+
29
+ function loadOpenShortform(ctx: StudioContext): DraftWorkflowItem[] {
30
+ const open: DraftWorkflowItem[] = [];
31
+ for (const w of listOpen(ctx.projectRoot, ctx.config)) {
32
+ if (w.contentKind === 'shortform') open.push(w);
33
+ }
34
+ open.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
35
+ return open;
36
+ }
37
+
38
+ function groupByPlatform(workflows: readonly DraftWorkflowItem[]): {
39
+ byPlatform: Map<string, DraftWorkflowItem[]>;
40
+ ordered: string[];
41
+ } {
42
+ const byPlatform = new Map<string, DraftWorkflowItem[]>();
43
+ for (const w of workflows) {
44
+ const key = w.platform ?? 'other';
45
+ const list = byPlatform.get(key) ?? [];
46
+ list.push(w);
47
+ byPlatform.set(key, list);
48
+ }
49
+ const ordered = [
50
+ ...PLATFORM_ORDER.filter((p) => byPlatform.has(p)),
51
+ ...[...byPlatform.keys()].filter(
52
+ (p) => !(PLATFORM_ORDER as readonly string[]).includes(p),
53
+ ),
54
+ ];
55
+ return { byPlatform, ordered };
56
+ }
57
+
58
+ function fmtRelTime(iso: string, now: Date): string {
59
+ const t = new Date(iso).getTime();
60
+ const s = Math.max(0, Math.floor((now.getTime() - t) / 1000));
61
+ if (s < 60) return `${s}s ago`;
62
+ const m = Math.floor(s / 60);
63
+ if (m < 60) return `${m}m ago`;
64
+ const h = Math.floor(m / 60);
65
+ if (h < 48) return `${h}h ago`;
66
+ return `${Math.floor(h / 24)}d ago`;
67
+ }
68
+
69
+ function renderRow(w: DraftWorkflowItem, now: Date): RawHtml {
70
+ const channelMarkup: RawHtml = w.channel
71
+ ? unsafe(html`<span class="channel">${w.channel}</span>`)
72
+ : unsafe('');
73
+ const reviewUrl = `/dev/editorial-review/${w.id}`;
74
+ return unsafe(html`
75
+ <a class="er-row er-shortform-row"
76
+ href="${reviewUrl}"
77
+ data-workflow-id="${w.id}"
78
+ data-platform="${w.platform ?? 'other'}"
79
+ data-state="${w.state}"
80
+ data-site="${w.site}">
81
+ <span class="er-row-num">→</span>
82
+ <span class="er-row-site er-row-site--${w.site}" title="${w.site}">${siteLabel(w.site)}</span>
83
+ <span class="er-row-slug">${w.slug}</span>
84
+ ${channelMarkup}
85
+ <span class="er-stamp er-stamp-${w.state}">${w.state.replace('-', ' ')}</span>
86
+ <span class="er-row-ts">v${w.currentVersion} · ${fmtRelTime(w.updatedAt, now)}</span>
87
+ <span class="er-row-hint">Open in review →</span>
88
+ </a>`);
89
+ }
90
+
91
+ function renderPlatformSection(
92
+ platform: string,
93
+ workflows: readonly DraftWorkflowItem[],
94
+ now: Date,
95
+ ): RawHtml {
96
+ const rows = workflows.map((w) => renderRow(w, now).__raw).join('');
97
+ return unsafe(html`
98
+ <section class="er-platform-section">
99
+ <div class="er-platform-header">
100
+ <h2>${platform}</h2>
101
+ <span class="er-platform-count">№ ${String(workflows.length).padStart(2, '0')}</span>
102
+ </div>
103
+ ${unsafe(rows)}
104
+ </section>`);
105
+ }
106
+
107
+ function renderEmptyState(): RawHtml {
108
+ const platformList = PLATFORM_ORDER.join(', ');
109
+ return unsafe(html`
110
+ <div class="er-empty" style="margin-top: var(--er-space-5);">
111
+ No short-form galleys on the desk.<br />
112
+ Supported platforms: <em>${platformList}</em>.<br />
113
+ Start a new shortform draft from the dashboard's
114
+ <a href="/dev/editorial-studio">coverage matrix</a>.
115
+ </div>`);
116
+ }
117
+
118
+ export function renderShortformPage(ctx: StudioContext): string {
119
+ const workflows = loadOpenShortform(ctx);
120
+ const { byPlatform, ordered } = groupByPlatform(workflows);
121
+ const now = ctx.now ? ctx.now() : new Date();
122
+
123
+ const cardsBlock =
124
+ workflows.length === 0
125
+ ? renderEmptyState().__raw
126
+ : ordered
127
+ .map((p) => renderPlatformSection(p, byPlatform.get(p) ?? [], now).__raw)
128
+ .join('');
129
+
130
+ const body = html`
131
+ ${renderEditorialFolio('reviews', 'shortform desk')}
132
+ <header class="er-pagehead er-pagehead--centered">
133
+ <p class="er-pagehead__kicker">All sites · short form</p>
134
+ <h1 class="er-pagehead__title">The <em>compositor</em>'s desk</h1>
135
+ <p class="er-pagehead__deck">Open shortform galleys — click any row to open the unified review surface.</p>
136
+ <p class="er-pagehead__meta">
137
+ <span>${workflows.length} in flight</span>
138
+ <span class="sep">·</span>
139
+ <span>${ordered.length} ${ordered.length === 1 ? 'platform' : 'platforms'}</span>
140
+ </p>
141
+ </header>
142
+ <main class="er-container" style="padding-top: var(--er-space-4); padding-bottom: var(--er-space-6);">
143
+ ${unsafe(cardsBlock)}
144
+ <p style="margin-top: var(--er-space-5); font-family: var(--er-font-display); font-style: italic; color: var(--er-faded);">
145
+ <a href="/dev/editorial-studio">← back to the studio</a>
146
+ </p>
147
+ </main>
148
+ <div class="er-toast" id="toast" hidden></div>`;
149
+
150
+ return layout({
151
+ title: 'Short form — all sites — dev',
152
+ cssHrefs: [
153
+ '/static/css/editorial-review.css',
154
+ '/static/css/editorial-nav.css',
155
+ '/static/css/editorial-studio.css',
156
+ ],
157
+ bodyAttrs: 'data-review-ui="shortform"',
158
+ bodyHtml: body,
159
+ embeddedJson: [],
160
+ scriptModules: ['/static/dist/editorial-studio-client.js'],
161
+ });
162
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deskwork/studio",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "type": "module",
5
5
  "description": "Editorial review studio — local web UI for the deskwork plugin",
6
6
  "homepage": "https://github.com/audiocontrol-org/deskwork#readme",
@@ -33,26 +33,26 @@
33
33
  "./package.json": "./package.json"
34
34
  },
35
35
  "scripts": {
36
- "build": "tsc -b tsconfig.build.json",
37
- "prepack": "tsc -b tsconfig.build.json",
36
+ "build": "tsc -b tsconfig.build.json && mkdir -p dist/pages && cp src/pages/*.ts dist/pages/",
37
+ "prepack": "tsc -b tsconfig.build.json && mkdir -p dist/pages && cp src/pages/*.ts dist/pages/",
38
38
  "test": "vitest run",
39
39
  "test:watch": "vitest",
40
40
  "typecheck": "tsc --noEmit"
41
41
  },
42
42
  "dependencies": {
43
- "@deskwork/core": "*",
44
- "@hono/node-server": "^1.13.7",
45
- "hono": "^4.6.0"
46
- },
47
- "devDependencies": {
48
43
  "@codemirror/commands": "^6.10.3",
49
44
  "@codemirror/lang-markdown": "^6.5.0",
50
45
  "@codemirror/language": "^6.12.3",
51
46
  "@codemirror/state": "^6.6.0",
52
47
  "@codemirror/view": "^6.41.1",
48
+ "@deskwork/core": "*",
49
+ "@hono/node-server": "^1.13.7",
53
50
  "@lezer/highlight": "^1.2.3",
54
- "@types/node": "^22.10.0",
55
51
  "esbuild": "^0.28.0",
52
+ "hono": "^4.6.0"
53
+ },
54
+ "devDependencies": {
55
+ "@types/node": "^22.10.0",
56
56
  "tsx": "^4.21.0",
57
57
  "typescript": "^5.7.0",
58
58
  "vitest": "^4.1.2"