@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.
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ ## @deskwork/studio
2
+
3
+ Local web studio for the [deskwork](https://github.com/audiocontrol-org/deskwork) editorial-calendar plugin. Hono-backed dev server exposing the editorial dashboard, a unified review surface for longform and shortform workflows, the scrapbook viewer, and the compositor's manual.
4
+
5
+ ### Audience
6
+
7
+ This package backs the [`deskwork-studio`](https://github.com/audiocontrol-org/deskwork/tree/main/plugins/deskwork-studio) Claude Code plugin shell. Direct npm install of `@deskwork/studio` into a non-deskwork host is unusual — the canonical entry point is the plugin shell, which `npm install`s this package on first run and dispatches via its bin shim.
8
+
9
+ If you want the editorial studio inside Claude Code, follow the marketplace install instructions at the [marketplace repo](https://github.com/audiocontrol-org/deskwork#install). The studio plugin pairs with the lifecycle plugin (`deskwork`) — install both, or just the lifecycle one for headless use.
10
+
11
+ ### Network model
12
+
13
+ The studio binds to loopback by default (`127.0.0.1`) plus the local Tailscale interface when one is detected, so peers on the same tailnet can reach it via magic-DNS without any flags. There is no authentication and no rate-limiting — it is dev-only by design. Public-internet exposure stays opt-in via `--host`.
14
+
15
+ ### Routes
16
+
17
+ | Path | Surface |
18
+ |---|---|
19
+ | `/dev/editorial-studio` | Calendar dashboard across all collections |
20
+ | `/dev/editorial-review/<id>` | Unified review surface for longform and shortform workflows |
21
+ | `/dev/editorial-review-shortform` | Shortform desk index (per-platform composition) |
22
+ | `/dev/editorial-help` | The compositor's manual |
23
+ | `/dev/scrapbook/<site>/<path>` | Scrapbook viewer at any depth |
24
+ | `/dev/content/<site>/<project>` | Bird's-eye content tree view |
25
+
26
+ ### Source
27
+
28
+ Repository: [`audiocontrol-org/deskwork`](https://github.com/audiocontrol-org/deskwork). The studio source lives at [`packages/studio/`](https://github.com/audiocontrol-org/deskwork/tree/main/packages/studio); shared core logic in [`@deskwork/core`](https://www.npmjs.com/package/@deskwork/core); the lifecycle CLI in [`@deskwork/cli`](https://www.npmjs.com/package/@deskwork/cli).
29
+
30
+ ### License
31
+
32
+ GPL-3.0-or-later — same as the monorepo. See [`LICENSE`](https://github.com/audiocontrol-org/deskwork/blob/main/LICENSE) for details.
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Editorial folio — sticky cross-page nav rendered atop every studio
3
+ * surface (dashboard, longform review, shortform, content view, scrapbook,
4
+ * help, and the studio index).
5
+ *
6
+ * Three-column grid: wordmark / nav / spine. The active surface gets a
7
+ * red-pencil tick mark drawn via `::before` — reads like an editor
8
+ * circled where you are, not a UI selected state.
9
+ *
10
+ * Phase 17: replaces the prior `renderEditorialChrome` (Writer's Catalog
11
+ * `.ed-chrome` strip), which only the bird's-eye content view used.
12
+ * The folio commits to the editorial-print design language so every
13
+ * surface shares one cross-page nav. CSS lives in
14
+ * `plugins/deskwork-studio/public/css/editorial-nav.css` and uses the
15
+ * existing `--er-*` tokens — no new variables.
16
+ */
17
+
18
+ import { html, unsafe, type RawHtml } from './html.ts';
19
+
20
+ export type ChromeActiveLink =
21
+ | 'index'
22
+ | 'dashboard'
23
+ | 'content'
24
+ | 'reviews'
25
+ | 'manual';
26
+
27
+ interface FolioLink {
28
+ key: ChromeActiveLink;
29
+ href: string;
30
+ label: string;
31
+ }
32
+
33
+ const NAV_LINKS: readonly FolioLink[] = [
34
+ { key: 'index', href: '/dev/', label: 'Index' },
35
+ { key: 'dashboard', href: '/dev/editorial-studio', label: 'Dashboard' },
36
+ { key: 'content', href: '/dev/content', label: 'Content' },
37
+ { key: 'reviews', href: '/dev/editorial-review-shortform', label: 'Reviews' },
38
+ { key: 'manual', href: '/dev/editorial-help', label: 'Manual' },
39
+ ];
40
+
41
+ /**
42
+ * Render the folio strip. `spineLabel` is the page-specific subtitle
43
+ * shown at the right edge ("press-check", "the shape of the work",
44
+ * "index of the press", etc.). Defaults to no spine when omitted —
45
+ * which collapses to a 2-column layout.
46
+ */
47
+ export function renderEditorialFolio(
48
+ active: ChromeActiveLink,
49
+ spineLabel?: string,
50
+ ): RawHtml {
51
+ const links = NAV_LINKS.map((link) => {
52
+ const cls = link.key === active ? 'active' : '';
53
+ return html`<a class="${cls}" href="${link.href}">${link.label}</a>`;
54
+ }).join('');
55
+
56
+ const spine = spineLabel
57
+ ? html`<div class="er-folio-spine">${spineLabel}</div>`
58
+ : '<div class="er-folio-spine" aria-hidden="true"></div>';
59
+
60
+ return unsafe(html`
61
+ <header class="er-folio">
62
+ <div class="er-folio-inner">
63
+ <div class="er-folio-name">deskwork <em>STUDIO</em></div>
64
+ <nav class="er-folio-nav" aria-label="Studio sections">
65
+ ${unsafe(links)}
66
+ </nav>
67
+ ${unsafe(spine)}
68
+ </div>
69
+ </header>`);
70
+ }
@@ -0,0 +1,352 @@
1
+ /**
2
+ * Detail panel for the bird's-eye content view (Phase 16d).
3
+ *
4
+ * Shows frontmatter, body preview, and scrapbook listing for a single
5
+ * tree node. Used as the right-hand panel of the drilldown view; also
6
+ * has an "empty placeholder" variant when no node is selected.
7
+ */
8
+
9
+ import { readFileSync, existsSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import {
12
+ formatRelativeTime,
13
+ listScrapbookAtDir,
14
+ scrapbookDirAtPath,
15
+ scrapbookDirForEntry,
16
+ type ScrapbookSummary,
17
+ } from '@deskwork/core/scrapbook';
18
+ import {
19
+ resolveContentDir,
20
+ } from '@deskwork/core/paths';
21
+ import type { ContentIndex } from '@deskwork/core/content-index';
22
+ import { parseFrontmatter } from '@deskwork/core/frontmatter';
23
+ import { renderMarkdownToHtml } from '@deskwork/core/review/render';
24
+ import type { StudioContext } from '../routes/api.ts';
25
+ import type { ContentNode } from '@deskwork/core/content-tree';
26
+ import { html, unsafe, type RawHtml } from './html.ts';
27
+ import {
28
+ renderEmptyScrapbookRow,
29
+ renderReadOnlyScrapbookRow,
30
+ scrapbookViewerUrl,
31
+ type InlineTextLoader,
32
+ } from '../components/scrapbook-item.ts';
33
+
34
+ const PREVIEW_CHAR_BUDGET = 480;
35
+
36
+ export function renderEmptyDetail(): RawHtml {
37
+ return unsafe(html`
38
+ <div class="detail detail--empty" data-detail-empty>
39
+ <div class="ornament" aria-hidden="true">· · ·</div>
40
+ <p class="text">
41
+ Select a node to read its head matter, preview its body, and
42
+ browse its scrapbook.
43
+ </p>
44
+ </div>`);
45
+ }
46
+
47
+ function safeReadFile(absPath: string): string | null {
48
+ try {
49
+ if (!existsSync(absPath)) return null;
50
+ return readFileSync(absPath, 'utf-8');
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function fmField(value: unknown): string {
57
+ if (value === null || value === undefined) return '';
58
+ if (typeof value === 'string') return value;
59
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
60
+ if (Array.isArray(value)) return value.map(fmField).join(', ');
61
+ return JSON.stringify(value);
62
+ }
63
+
64
+ function renderFrontmatter(fm: Record<string, unknown>): RawHtml {
65
+ const keys = Object.keys(fm);
66
+ if (keys.length === 0) {
67
+ return unsafe(html`<p class="frontmatter-empty">No frontmatter detected.</p>`);
68
+ }
69
+ const rows = keys.map(
70
+ (k) => html`<dt>${k}</dt><dd>${fmField(fm[k])}</dd>`,
71
+ );
72
+ return unsafe(html`
73
+ <dl class="frontmatter">
74
+ ${unsafe(rows.join(''))}
75
+ </dl>`);
76
+ }
77
+
78
+ async function renderBodyPreview(body: string): Promise<RawHtml> {
79
+ if (body.trim().length === 0) {
80
+ return unsafe(html`<p class="preview-empty">No body content yet.</p>`);
81
+ }
82
+ const slice =
83
+ body.length > PREVIEW_CHAR_BUDGET * 2
84
+ ? `${body.slice(0, PREVIEW_CHAR_BUDGET * 2)}\n\n…`
85
+ : body;
86
+ const rendered = await renderMarkdownToHtml(slice);
87
+ return unsafe(html`<div class="preview">${unsafe(rendered)}</div>`);
88
+ }
89
+
90
+ /**
91
+ * Resolve the on-disk scrapbook directory for a content tree node.
92
+ *
93
+ * Precedence — the index-driven path is preferred so writingcontrol-
94
+ * shape entries (slug != fs path) read scrapbook items from the actual
95
+ * file location:
96
+ *
97
+ * 1. The node carries a calendar entry with a stable `id` AND a
98
+ * per-request content index is wired → resolve via
99
+ * `scrapbookDirForEntry`.
100
+ * 2. Otherwise → resolve via `scrapbookDirAtPath` against the node's
101
+ * fs-derived `path`. Works for both organizational nodes (no
102
+ * entry) and pre-doctor entries (no id binding yet) since
103
+ * `node.path` is always the structural key already validated by
104
+ * the content-tree builder.
105
+ *
106
+ * The shared scrapbook directory lookup avoids duplicating the
107
+ * fall-through logic between the inline-text loader and the scrapbook
108
+ * listing in `loadDetailRender`.
109
+ */
110
+ function resolveNodeScrapbookDir(
111
+ ctx: StudioContext,
112
+ site: string,
113
+ node: ContentNode,
114
+ index?: ContentIndex,
115
+ ): string {
116
+ if (node.entry !== null && node.entry.id !== undefined && node.entry.id !== '') {
117
+ return scrapbookDirForEntry(
118
+ ctx.projectRoot,
119
+ ctx.config,
120
+ site,
121
+ node.entry,
122
+ index,
123
+ );
124
+ }
125
+ // node.path is always a valid kebab-case path (the content-tree
126
+ // builder enforces this); scrapbookDirAtPath accepts that shape.
127
+ // Used directly because the path is already filesystem-derived —
128
+ // bypassing the slug template that wouldn't resolve correctly for
129
+ // hierarchical / relocated entries.
130
+ return scrapbookDirAtPath(ctx.projectRoot, ctx.config, site, node.path);
131
+ }
132
+
133
+ function makeInlineTextLoaderForNode(
134
+ ctx: StudioContext,
135
+ site: string,
136
+ node: ContentNode,
137
+ index?: ContentIndex,
138
+ ): InlineTextLoader {
139
+ let scrapbookDir: string;
140
+ try {
141
+ scrapbookDir = resolveNodeScrapbookDir(ctx, site, node, index);
142
+ } catch {
143
+ // Defensive: a malformed path or unresolvable entry shouldn't blow
144
+ // up the detail panel. Fall back to the legacy slug-template
145
+ // computation — the loader will just return null for every read.
146
+ const contentDir = resolveContentDir(ctx.projectRoot, ctx.config, site);
147
+ scrapbookDir = join(contentDir, node.path, 'scrapbook');
148
+ }
149
+ return (filename, maxBytes) => {
150
+ try {
151
+ const buf = readFileSync(join(scrapbookDir, filename));
152
+ const slice = buf.subarray(0, Math.min(buf.byteLength, maxBytes));
153
+ return slice.toString('utf-8');
154
+ } catch {
155
+ return null;
156
+ }
157
+ };
158
+ }
159
+
160
+ function renderScrapbookList(
161
+ site: string,
162
+ slug: string,
163
+ summary: ScrapbookSummary | null,
164
+ loader: InlineTextLoader,
165
+ ): RawHtml {
166
+ if (!summary || (summary.items.length === 0 && summary.secretItems.length === 0)) {
167
+ return renderEmptyScrapbookRow();
168
+ }
169
+ const itemRows = summary.items.map((item) =>
170
+ renderReadOnlyScrapbookRow({ site, path: slug }, item, {
171
+ inlinePreviewLoader: loader,
172
+ }),
173
+ );
174
+ const secretRows = summary.secretItems.map((item) =>
175
+ renderReadOnlyScrapbookRow({ site, path: slug }, item, {
176
+ inlinePreviewLoader: loader,
177
+ }),
178
+ );
179
+ if (secretRows.length === 0) {
180
+ return unsafe(html`<div class="scraplist">${itemRows}</div>`);
181
+ }
182
+ return unsafe(html`
183
+ <div class="scraplist">${itemRows}</div>
184
+ <p class="scraplist-secret-head">
185
+ <span aria-hidden="true">⚿</span> secret · ${summary.secretItems.length}
186
+ </p>
187
+ <div class="scraplist scraplist--secret">${secretRows}</div>`);
188
+ }
189
+
190
+ interface DetailRender {
191
+ frontmatter: Record<string, unknown>;
192
+ bodyPreview: string;
193
+ scrapbook: ScrapbookSummary | null;
194
+ }
195
+
196
+ /**
197
+ * Find the on-disk markdown for an organizational node — try
198
+ * `<slug>/index.md`, then `<slug>/README.md` (and `.mdx`/`.markdown`
199
+ * variants). Used when the node has no calendar entry but the
200
+ * filesystem walk found a directory with a recognizable index file
201
+ * (#24, v0.6.0). Returns the first match, or null.
202
+ */
203
+ function findOrganizationalIndex(
204
+ contentDir: string,
205
+ slug: string,
206
+ ): string | null {
207
+ const candidates = [
208
+ 'index.md', 'index.mdx', 'index.markdown',
209
+ 'README.md', 'README.mdx', 'README.markdown',
210
+ ];
211
+ for (const name of candidates) {
212
+ const abs = join(contentDir, slug, name);
213
+ if (existsSync(abs)) return abs;
214
+ }
215
+ return null;
216
+ }
217
+
218
+ function loadDetailRender(
219
+ ctx: StudioContext,
220
+ site: string,
221
+ node: ContentNode,
222
+ index?: ContentIndex,
223
+ ): DetailRender {
224
+ const contentDir = resolveContentDir(ctx.projectRoot, ctx.config, site);
225
+ let frontmatter: Record<string, unknown> = {};
226
+ let bodyPreview = '';
227
+ let scrapbook: ScrapbookSummary | null = null;
228
+
229
+ // Phase 19c: detail-panel file lookup uses the node's fs `path` —
230
+ // the structural key. Whether the node is a tracked entry or
231
+ // organizational, the file lookup walks `<contentDir>/<path>/`
232
+ // for an index/README. (Phase 19d will additionally consult the
233
+ // per-request content index for tracked entries to handle the case
234
+ // where the host's template differs from the file's actual path.)
235
+ const idxFile = findOrganizationalIndex(contentDir, node.path);
236
+ if (idxFile !== null) {
237
+ const raw = safeReadFile(idxFile);
238
+ if (raw !== null) {
239
+ const parsed = parseFrontmatter(raw);
240
+ frontmatter = parsed.data as Record<string, unknown>;
241
+ bodyPreview = parsed.body;
242
+ }
243
+ } else if (node.hasFsDir && node.hasOwnIndex) {
244
+ // Organizational node (#24, v0.6.0): no calendar entry, but the
245
+ // fs walk found a directory with an index/README. Read that file
246
+ // for the detail panel so the operator sees the structural prose
247
+ // (e.g. "These are the characters in The Outbound") even though
248
+ // nothing about this node ships through the lifecycle pipeline.
249
+ const abs = findOrganizationalIndex(contentDir, node.path);
250
+ if (abs !== null) {
251
+ const raw = safeReadFile(abs);
252
+ if (raw !== null) {
253
+ const parsed = parseFrontmatter(raw);
254
+ frontmatter = parsed.data as Record<string, unknown>;
255
+ bodyPreview = parsed.body;
256
+ }
257
+ }
258
+ }
259
+
260
+ try {
261
+ // Phase 19c+: the scrapbook listing prefers the index-driven dir
262
+ // for tracked entries (id binding) and falls back to the path-
263
+ // driven dir for organizational nodes. `scrapbookDirAtPath` is the
264
+ // right primitive here because `node.path` is already filesystem-
265
+ // derived — no slug template to substitute.
266
+ const scrapDir = resolveNodeScrapbookDir(ctx, site, node, index);
267
+ scrapbook = listScrapbookAtDir(site, node.path, scrapDir);
268
+ } catch {
269
+ scrapbook = null;
270
+ }
271
+
272
+ return { frontmatter, bodyPreview, scrapbook };
273
+ }
274
+
275
+ export async function renderNodeDetail(
276
+ ctx: StudioContext,
277
+ site: string,
278
+ node: ContentNode,
279
+ index?: ContentIndex,
280
+ ): Promise<RawHtml> {
281
+ const detail = loadDetailRender(ctx, site, node, index);
282
+ const fmCount = Object.keys(detail.frontmatter).length;
283
+ // Phase 19d: prefer the entry's stable id for the canonical review
284
+ // URL — refactor-proof, survives slug renames. Falls back to the
285
+ // entry slug (or the node's path) when the entry has no id stamped.
286
+ const reviewKey =
287
+ node.entry !== null && node.entry.id !== undefined && node.entry.id !== ''
288
+ ? node.entry.id
289
+ : (node.slug ?? node.path);
290
+ const reviewHref = `/dev/editorial-review/${encodeURI(reviewKey)}?site=${site}`;
291
+ // Scrapbook viewer addresses by fs path — every node has a
292
+ // deterministic on-disk scrapbook location at `<path>/scrapbook/`.
293
+ const scrapHref = scrapbookViewerUrl({ site, path: node.path });
294
+ const scrapDirHint =
295
+ node.scrapbookCount === 0
296
+ ? '0 items · scrapbook empty'
297
+ : `${node.scrapbookCount} items · /${node.path}/scrapbook`;
298
+ const updatedHint =
299
+ node.scrapbookMostRecentMtime !== null
300
+ ? html`<span class="detail__updated">last touched ${formatRelativeTime(node.scrapbookMostRecentMtime)}</span>`
301
+ : '';
302
+ const loader = makeInlineTextLoaderForNode(ctx, site, node, index);
303
+ const previewBlock = await renderBodyPreview(detail.bodyPreview);
304
+ const reviewBtn =
305
+ node.entry !== null
306
+ ? html`<a class="btn btn--accent" href="${reviewHref}">Open in Review</a>`
307
+ : '';
308
+ // Phase 19c: when an entry overlay carries a slug, surface it as
309
+ // the "public URL" hover hint. The slug is the host-rendering
310
+ // engine's identifier — operators recognize it as the SEO URL,
311
+ // distinct from the fs path that drives the tree.
312
+ const publicUrlHint =
313
+ node.slug !== undefined && node.slug !== node.path
314
+ ? html`<span class="detail__public-url" title="public URL on the host site">
315
+ public URL: /blog/${node.slug}
316
+ </span>`
317
+ : '';
318
+
319
+ return unsafe(html`
320
+ <div class="detail" data-node-detail data-slug="${node.path}">
321
+ <div class="detail__crumb">${node.path.replaceAll('/', ' · ')}</div>
322
+ <h2 class="detail__title">${node.title}</h2>
323
+ <p class="detail__sub">
324
+ ${node.entry?.description ?? ''}
325
+ ${unsafe(updatedHint)}
326
+ ${unsafe(publicUrlHint)}
327
+ </p>
328
+
329
+ <div class="detail__sectionhead">
330
+ Frontmatter
331
+ <span class="marg">${fmCount} ${fmCount === 1 ? 'field' : 'fields'} · raw</span>
332
+ </div>
333
+ ${renderFrontmatter(detail.frontmatter)}
334
+
335
+ <div class="detail__sectionhead">
336
+ Preview
337
+ <span class="marg">first paragraphs · markdown rendered</span>
338
+ </div>
339
+ ${previewBlock}
340
+
341
+ <div class="detail__sectionhead">
342
+ Scrapbook
343
+ <span class="marg">${scrapDirHint}</span>
344
+ </div>
345
+ ${renderScrapbookList(site, node.path, detail.scrapbook, loader)}
346
+
347
+ <div class="actions">
348
+ ${unsafe(reviewBtn)}
349
+ <a class="btn" href="${scrapHref}">Open Scrapbook</a>
350
+ </div>
351
+ </div>`);
352
+ }