@deskwork/studio 0.9.5 → 0.9.7

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,532 @@
1
+ /**
2
+ * Bird's-eye content view (Phase 16d).
3
+ *
4
+ * Three render entry points sharing a Writer's-Catalog visual language
5
+ * extracted from `mockups/birds-eye-content-view.html`:
6
+ *
7
+ * - `renderContentTopLevel(ctx)` — `/dev/content` and `/dev/content/:site`
8
+ * → site cards + per-site project rollups.
9
+ * - `renderContentProject(ctx, site, project, selectedSlug)` —
10
+ * `/dev/content/:site/:project{.+}` → drilldown view with the
11
+ * per-project tree on the left and the detail panel on the right.
12
+ * - The detail panel is selected via the `?node=<slug>` query
13
+ * param. When absent, an empty placeholder renders.
14
+ *
15
+ * Read-only — no calendar mutations. Operators jump back to the
16
+ * standalone scrapbook viewer or the longform review surface for
17
+ * mutations via the inline `→ review` and `→ scrapbook` affordances
18
+ * on each tree row.
19
+ *
20
+ * Helpers:
21
+ * - `pages/content-detail.ts` — detail panel rendering.
22
+ * - `pages/chrome.ts` — cross-page editorial folio strip (`renderEditorialFolio`).
23
+ * - `components/scrapbook-item.ts` — shared scrap-row renderer.
24
+ * - `@deskwork/core/content-tree` — pure tree assembly.
25
+ */
26
+
27
+ import { readCalendar } from '@deskwork/core/calendar';
28
+ import {
29
+ buildContentTree,
30
+ findNode,
31
+ flattenForRender,
32
+ type ContentNode,
33
+ type ContentProject,
34
+ type FlatNode,
35
+ } from '@deskwork/core/content-tree';
36
+ import type { ContentIndex } from '@deskwork/core/content-index';
37
+ import {
38
+ formatRelativeTime,
39
+ } from '@deskwork/core/scrapbook';
40
+ import { resolveCalendarPath, resolveContentDir } from '@deskwork/core/paths';
41
+ import { relative } from 'node:path';
42
+ import type { Stage } from '@deskwork/core/types';
43
+ import type { StudioContext } from '../routes/api.ts';
44
+ import { html, unsafe, type RawHtml } from './html.ts';
45
+ import { layout } from './layout.ts';
46
+ import { renderEditorialFolio } from './chrome.ts';
47
+ import { renderEmptyDetail, renderNodeDetail } from './content-detail.ts';
48
+ import { scrapbookViewerUrl } from '../components/scrapbook-item.ts';
49
+
50
+ /**
51
+ * Per-request index getter — supplied by the route layer (which
52
+ * pulls memoized indices off the Hono context). Optional: when
53
+ * omitted, renderers fall back to building the index per call.
54
+ */
55
+ export type IndexGetter = (site: string) => ContentIndex;
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Per-site project loading
59
+ // ---------------------------------------------------------------------------
60
+
61
+ interface SiteProjects {
62
+ site: string;
63
+ /** Host is undefined for collections that aren't published as a website. */
64
+ host: string | undefined;
65
+ projects: ContentProject[];
66
+ }
67
+
68
+ function loadProjectsForSite(
69
+ ctx: StudioContext,
70
+ site: string,
71
+ getIndex?: IndexGetter,
72
+ ): SiteProjects {
73
+ const calendarPath = resolveCalendarPath(ctx.projectRoot, ctx.config, site);
74
+ const cal = readCalendar(calendarPath);
75
+ const contentIndex = getIndex ? getIndex(site) : undefined;
76
+ const projects = buildContentTree(
77
+ site,
78
+ cal.entries,
79
+ ctx.config,
80
+ ctx.projectRoot,
81
+ contentIndex !== undefined ? { contentIndex } : {},
82
+ );
83
+ return {
84
+ site,
85
+ host: ctx.config.sites[site].host,
86
+ projects,
87
+ };
88
+ }
89
+
90
+ function loadAllSites(
91
+ ctx: StudioContext,
92
+ getIndex?: IndexGetter,
93
+ ): SiteProjects[] {
94
+ return Object.keys(ctx.config.sites).map((site) =>
95
+ loadProjectsForSite(ctx, site, getIndex),
96
+ );
97
+ }
98
+
99
+ interface AggregateCounts {
100
+ sites: number;
101
+ trackedNodes: number;
102
+ scrapbookItems: number;
103
+ }
104
+
105
+ function aggregateCounts(siteProjects: readonly SiteProjects[]): AggregateCounts {
106
+ let trackedNodes = 0;
107
+ let scrapbookItems = 0;
108
+ for (const sp of siteProjects) {
109
+ for (const project of sp.projects) {
110
+ trackedNodes += project.trackedCount;
111
+ scrapbookItems += project.scrapbookCount;
112
+ }
113
+ }
114
+ return { sites: siteProjects.length, trackedNodes, scrapbookItems };
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Lane styling helpers
119
+ // ---------------------------------------------------------------------------
120
+
121
+ function laneToken(stage: Stage | null): string {
122
+ return stage ? stage.toLowerCase() : 'unknown';
123
+ }
124
+
125
+ function laneLabel(stage: Stage | null): string {
126
+ return stage ? `mostly ${stage.toLowerCase()}` : 'untracked';
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Top-level: site cards + project rows
131
+ // ---------------------------------------------------------------------------
132
+
133
+ function projectFormHint(project: ContentProject): string {
134
+ if (project.totalNodes === 1) return 'flat entry';
135
+ if (project.maxDepth >= 3) return `nested · ${project.totalNodes} nodes`;
136
+ return `collection · ${project.totalNodes} nodes`;
137
+ }
138
+
139
+ function renderProjectRow(
140
+ site: string,
141
+ project: ContentProject,
142
+ index: number,
143
+ ): RawHtml {
144
+ const num = String(index + 1).padStart(2, '0');
145
+ const lane = laneToken(project.predominantLane);
146
+ const href = `/dev/content/${site}/${encodeURI(project.rootSlug)}`;
147
+ return unsafe(html`
148
+ <a class="project-row" href="${href}">
149
+ <span class="project-row__num">${num}</span>
150
+ <span class="project-row__name">
151
+ ${project.title}
152
+ <em>${projectFormHint(project)}</em>
153
+ </span>
154
+ <span class="project-row__nodes">${project.totalNodes} nodes</span>
155
+ <span class="project-row__lane">
156
+ <span class="lane-dot lane-dot--${lane}"></span>
157
+ ${laneLabel(project.predominantLane)}
158
+ </span>
159
+ </a>`);
160
+ }
161
+
162
+ function siteTag(index: number): string {
163
+ return index === 0 ? 'Site · primary' : 'Site · auxiliary';
164
+ }
165
+
166
+ function renderSiteCard(sp: SiteProjects, index: number): RawHtml {
167
+ const totalNodes = sp.projects.reduce((acc, p) => acc + p.totalNodes, 0);
168
+ const trackedNodes = sp.projects.reduce(
169
+ (acc, p) => acc + p.trackedCount,
170
+ 0,
171
+ );
172
+ const scrapbookItems = sp.projects.reduce(
173
+ (acc, p) => acc + p.scrapbookCount,
174
+ 0,
175
+ );
176
+ return unsafe(html`
177
+ <article class="site-card">
178
+ <div class="site-card__tag">${siteTag(index)}</div>
179
+ <h2 class="site-card__name">${sp.site}</h2>
180
+ <div class="site-card__host">${sp.host ?? '(no host configured)'}</div>
181
+ <div class="site-card__counts">
182
+ <b>${sp.projects.length}</b> root entries ·
183
+ <b>${totalNodes}</b> total nodes ·
184
+ <b>${scrapbookItems}</b> scrapbook items
185
+ </div>
186
+ <div class="site-card__projects">
187
+ ${sp.projects.map((p, i) => renderProjectRow(sp.site, p, i))}
188
+ </div>
189
+ ${
190
+ sp.projects.length === 0
191
+ ? unsafe(html`
192
+ <p class="site-card__empty">No tracked content yet — run
193
+ <code>/deskwork:add</code> or
194
+ <code>/deskwork:ingest</code>.
195
+ </p>`)
196
+ : ''
197
+ }
198
+ <p class="site-card__rollup">
199
+ ${trackedNodes} tracked · ${totalNodes - trackedNodes} synthetic
200
+ </p>
201
+ </article>`);
202
+ }
203
+
204
+ export function renderContentTopLevel(
205
+ ctx: StudioContext,
206
+ getIndex?: IndexGetter,
207
+ ): string {
208
+ const sites = loadAllSites(ctx, getIndex);
209
+ const counts = aggregateCounts(sites);
210
+
211
+ const body = html`
212
+ ${renderEditorialFolio('content', 'the shape of the work')}
213
+ <main class="content-page">
214
+ <header class="er-pagehead er-pagehead--split er-pagehead--compact">
215
+ <div>
216
+ <h1 class="er-pagehead__title">A <em>shape</em> of the work.</h1>
217
+ <p class="er-pagehead__deck">
218
+ The pipeline view shows where things are. This shows what's
219
+ there. Browse the corpus by its tree on disk; drill into any
220
+ node to see its content and the scrapbook hanging off it.
221
+ </p>
222
+ </div>
223
+ <p class="er-pagehead__meta">
224
+ <span><b>${counts.sites}</b> SITES</span>
225
+ <span><b>${counts.trackedNodes}</b> TRACKED NODES</span>
226
+ <span><b>${counts.scrapbookItems}</b> SCRAPBOOK ITEMS</span>
227
+ </p>
228
+ </header>
229
+ <section class="toplevel">
230
+ ${sites.map((sp, i) => renderSiteCard(sp, i))}
231
+ </section>
232
+ </main>`;
233
+
234
+ return layout({
235
+ title: 'Content — deskwork',
236
+ cssHrefs: [
237
+ '/static/css/editorial-review.css',
238
+ '/static/css/editorial-nav.css',
239
+ '/static/css/content.css',
240
+ '/static/css/scrap-row.css',
241
+ '/static/css/blog-figure.css',
242
+ ],
243
+ bodyAttrs: 'data-review-ui="studio"',
244
+ bodyHtml: body,
245
+ // #29: lightbox listener for image thumbnails in detail-panel
246
+ // scrap rows. Idempotent — safe to load on the top-level page
247
+ // too (no scrap rows there → no work).
248
+ scriptModules: ['/static/dist/content-view-client.js'],
249
+ });
250
+ }
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // Drilldown view: tree + detail panel
254
+ // ---------------------------------------------------------------------------
255
+
256
+ function renderTreeBreadcrumb(
257
+ site: string,
258
+ project: ContentProject,
259
+ selectedPath: string | null,
260
+ ): RawHtml {
261
+ const links: string[] = [];
262
+ links.push(html`<a href="/dev/content/${site}">${site}</a>`);
263
+ if (selectedPath === null) {
264
+ links.push(html`<b>${project.rootSlug}</b>`);
265
+ } else {
266
+ const projectHref = `/dev/content/${site}/${encodeURI(project.rootSlug)}`;
267
+ links.push(html`<a href="${projectHref}">${project.rootSlug}</a>`);
268
+ const segments = selectedPath.split('/');
269
+ for (let i = 1; i < segments.length; i++) {
270
+ const path = segments.slice(0, i + 1).join('/');
271
+ const isLast = i === segments.length - 1;
272
+ if (isLast) {
273
+ links.push(html`<b>${segments[i]}</b>`);
274
+ } else {
275
+ const href = `${projectHref}?node=${encodeURIComponent(path)}`;
276
+ links.push(html`<a href="${href}">${segments[i]}</a>`);
277
+ }
278
+ }
279
+ }
280
+ const sep = html`<span class="breadcrumb__sep" aria-hidden="true">›</span>`;
281
+ return unsafe(html`
282
+ <nav class="breadcrumb" aria-label="content tree breadcrumb">
283
+ ${unsafe(links.join(`\n${sep}\n`))}
284
+ </nav>`);
285
+ }
286
+
287
+ function nodeIcon(node: ContentNode): RawHtml {
288
+ if (node.children.length > 0 || node.lane === null) {
289
+ return unsafe(
290
+ html`<span class="tree-row__icon is-branch" aria-hidden="true">◐</span>`,
291
+ );
292
+ }
293
+ return unsafe(html`<span class="tree-row__icon" aria-hidden="true">·</span>`);
294
+ }
295
+
296
+ function nodeFilePathHint(node: ContentNode, contentDir: string): string {
297
+ // Issue #70: when the content index has a real file binding for this
298
+ // node, display the actual on-disk path (relative to contentDir) so
299
+ // the operator sees where the file actually lives — not a slug-derived
300
+ // ghost path that may not exist on disk. Hierarchical layouts where
301
+ // the file is at `<path>/prd.md` (not `<path>/index.md`) used to
302
+ // render the wrong path here.
303
+ if (node.filePath !== undefined) {
304
+ const rel = relative(contentDir, node.filePath);
305
+ // `relative` may emit `../` segments if the file lives outside
306
+ // contentDir (shouldn't happen, but defend against it). Fall through
307
+ // to the slug-derived hint when that's the case so we never display
308
+ // an escaping path string in the UI.
309
+ if (!rel.startsWith('..')) return `/${rel}`;
310
+ }
311
+ // Phase 19c: node.path is the fs-relative path (the structural key).
312
+ // Tracked entries display the index file shape; organizational
313
+ // nodes show the directory shape. The host's actual file basename
314
+ // could differ (README.md, .mdx, etc.) but `/index.md` is the
315
+ // universal "expected location" hint the operator reads.
316
+ if (node.entry !== null) return `/${node.path}/index.md`;
317
+ return `/${node.path}/`;
318
+ }
319
+
320
+ /**
321
+ * Last segment of an fs-relative path. Used to detect when a tracked
322
+ * entry's public-URL slug differs from where it lives on disk —
323
+ * e.g. an entry whose `path = "projects/the-outbound-novel"` and
324
+ * `slug = "the-outbound"` (renamed directory, slug unchanged for SEO).
325
+ */
326
+ function pathLeaf(path: string): string {
327
+ const idx = path.lastIndexOf('/');
328
+ return idx < 0 ? path : path.slice(idx + 1);
329
+ }
330
+
331
+ function renderTreeRowMeta(node: ContentNode): RawHtml {
332
+ const meta: string[] = [];
333
+ if (node.scrapbookCount > 0) {
334
+ const word = node.scrapbookCount === 1 ? 'note' : 'notes';
335
+ meta.push(html`<span class="scrap-count">${node.scrapbookCount} ${word}</span>`);
336
+ }
337
+ if (node.scrapbookMostRecentMtime !== null) {
338
+ meta.push(html`<span class="mtime">${formatRelativeTime(node.scrapbookMostRecentMtime)}</span>`);
339
+ }
340
+ return unsafe(meta.join(''));
341
+ }
342
+
343
+ function renderTreeRowActions(node: ContentNode, site: string): RawHtml {
344
+ // Phase 19d: when an entry is overlaid, prefer its stable id for
345
+ // the canonical review URL (refactor-proof — survives slug renames).
346
+ // Fall back to the entry's slug (or the node's path for organizational
347
+ // nodes) when no id is stamped — that's the legacy migration shape;
348
+ // server.ts 302-redirects it to the canonical URL.
349
+ const reviewKey =
350
+ node.entry !== null && node.entry.id !== undefined && node.entry.id !== ''
351
+ ? node.entry.id
352
+ : (node.slug ?? node.path);
353
+ const reviewHref = `/dev/editorial-review/${encodeURI(reviewKey)}?site=${site}`;
354
+ // Scrapbook addressing uses fs path — every node, tracked or not,
355
+ // has a deterministic on-disk scrapbook location at `<path>/scrapbook/`.
356
+ const scrapHref = scrapbookViewerUrl({ site, path: node.path });
357
+ const reviewLink =
358
+ node.entry !== null
359
+ ? html`<a class="tree-row__action tree-row__action--review" href="${reviewHref}"
360
+ tabindex="0" aria-label="Open review for ${node.title}">→ review</a>`
361
+ : '';
362
+ const scrapLink =
363
+ node.scrapbookCount > 0
364
+ ? html`<a class="tree-row__action" href="${scrapHref}"
365
+ tabindex="0" aria-label="Open scrapbook for ${node.title}">→ scrapbook</a>`
366
+ : '';
367
+ return unsafe(reviewLink + scrapLink);
368
+ }
369
+
370
+ function renderTreeRow(
371
+ site: string,
372
+ project: ContentProject,
373
+ flat: FlatNode,
374
+ selectedPath: string | null,
375
+ contentDir: string,
376
+ ): RawHtml {
377
+ const { node, depth, isLast } = flat;
378
+ const isSelected = selectedPath === node.path;
379
+ const isLeaf = node.children.length === 0;
380
+ const lane = laneToken(node.lane);
381
+ const projectHref = `/dev/content/${site}/${encodeURI(project.rootSlug)}`;
382
+ // Phase 19c: structural URLs key on fs path. The selection query
383
+ // parameter accepts hierarchical paths via `:path{.+}` route syntax
384
+ // upstream — encodeURIComponent preserves the `/` segments.
385
+ const nodeHref = `${projectHref}?node=${encodeURIComponent(node.path)}`;
386
+ const classes = [
387
+ 'tree-row',
388
+ isLeaf ? 'is-leaf' : 'is-branch',
389
+ isLast ? 'is-last' : '',
390
+ isSelected ? 'is-selected' : '',
391
+ ]
392
+ .filter(Boolean)
393
+ .join(' ');
394
+
395
+ // Phase 19d: render a "public URL" hover hint when an overlay entry
396
+ // exists AND the entry's slug differs from the path's leaf segment.
397
+ // The slug is the host-rendering engine's identifier (the SEO URL);
398
+ // showing it explicitly clarifies the relationship between the
399
+ // structural fs path and the public-facing URL.
400
+ const publicUrlHint =
401
+ node.slug !== undefined && node.slug !== pathLeaf(node.path)
402
+ ? unsafe(html`<span class="tree-row__public-url"
403
+ title="public URL on the host site">/blog/${node.slug}</span>`)
404
+ : unsafe('');
405
+
406
+ // The HTML attribute name `data-slug` is preserved for backward
407
+ // compatibility with the client-side selectors; it now carries the
408
+ // fs path. A future cleanup can rename the attribute to data-path.
409
+ return unsafe(html`
410
+ <a class="${classes}" href="${nodeHref}" style="--depth: ${depth}"
411
+ data-slug="${node.path}" aria-current="${isSelected ? 'true' : 'false'}">
412
+ <div class="tree-row__main">
413
+ ${nodeIcon(node)}
414
+ <span class="tree-row__title">${node.title}</span>
415
+ <span class="tree-row__slug">${nodeFilePathHint(node, contentDir)}</span>
416
+ ${publicUrlHint}
417
+ </div>
418
+ <span class="tree-row__lane">
419
+ <span class="lane-dot lane-dot--${lane}"></span>
420
+ ${node.lane ? node.lane.toLowerCase() : 'untracked'}
421
+ </span>
422
+ <span class="tree-row__meta">${renderTreeRowMeta(node)}</span>
423
+ <span class="tree-row__actions">${renderTreeRowActions(node, site)}</span>
424
+ </a>`);
425
+ }
426
+
427
+ function renderTree(
428
+ site: string,
429
+ project: ContentProject,
430
+ selectedPath: string | null,
431
+ contentDir: string,
432
+ ): RawHtml {
433
+ const flat = flattenForRender(project.root);
434
+ return unsafe(html`
435
+ <div class="tree" role="tree">
436
+ ${flat.map((f) => renderTreeRow(site, project, f, selectedPath, contentDir))}
437
+ </div>`);
438
+ }
439
+
440
+ // ---------------------------------------------------------------------------
441
+ // Project drilldown render entry
442
+ // ---------------------------------------------------------------------------
443
+
444
+ export async function renderContentProject(
445
+ ctx: StudioContext,
446
+ site: string,
447
+ projectSlug: string,
448
+ selectedPath: string | null,
449
+ getIndex?: IndexGetter,
450
+ ): Promise<{ status: number; html: string }> {
451
+ if (!(site in ctx.config.sites)) {
452
+ return { status: 404, html: renderNotFound(`unknown site: ${site}`) };
453
+ }
454
+ const sp = loadProjectsForSite(ctx, site, getIndex);
455
+ const project = sp.projects.find((p) => p.rootSlug === projectSlug);
456
+ if (!project) {
457
+ return {
458
+ status: 404,
459
+ html: renderNotFound(`unknown project: ${projectSlug} on ${site}`),
460
+ };
461
+ }
462
+
463
+ const selectedNode = selectedPath ? findNode(project, selectedPath) : null;
464
+ // Phase 19c+: hand the per-request content index to the detail
465
+ // panel. The panel uses it for id-driven scrapbook lookups so
466
+ // writingcontrol-shape entries find their items at the actual file
467
+ // location. Falls through to slug/path resolution for unbound nodes.
468
+ const detailIndex = getIndex ? getIndex(site) : undefined;
469
+ const detailBlock = selectedNode
470
+ ? await renderNodeDetail(ctx, site, selectedNode, detailIndex)
471
+ : renderEmptyDetail();
472
+
473
+ const body = html`
474
+ ${renderEditorialFolio('content', `drilldown · ${project.rootSlug}`)}
475
+ <main class="content-page">
476
+ <section class="drilldown">
477
+ <div class="drilldown__tree">
478
+ ${renderTreeBreadcrumb(site, project, selectedNode?.path ?? null)}
479
+ <header class="tree-head">
480
+ <h2 class="tree-head__title">${project.title}</h2>
481
+ <span class="tree-head__count">
482
+ ${project.totalNodes} NODES · ${project.maxDepth} LEVELS DEEP
483
+ </span>
484
+ </header>
485
+ ${renderTree(site, project, selectedNode?.path ?? null, resolveContentDir(ctx.projectRoot, ctx.config, site))}
486
+ </div>
487
+ ${detailBlock}
488
+ </section>
489
+ </main>`;
490
+
491
+ return {
492
+ status: 200,
493
+ html: layout({
494
+ title: `${project.title} · content — deskwork`,
495
+ cssHrefs: [
496
+ '/static/css/editorial-review.css',
497
+ '/static/css/editorial-nav.css',
498
+ '/static/css/content.css',
499
+ '/static/css/scrap-row.css',
500
+ '/static/css/blog-figure.css',
501
+ ],
502
+ bodyAttrs: 'data-review-ui="studio"',
503
+ bodyHtml: body,
504
+ // #29: scrap rows in the detail panel have image thumbnails;
505
+ // wire up the lightbox.
506
+ scriptModules: ['/static/dist/content-view-client.js'],
507
+ }),
508
+ };
509
+ }
510
+
511
+ function renderNotFound(message: string): string {
512
+ const body = html`
513
+ ${renderEditorialFolio('content', 'not found')}
514
+ <main class="content-page">
515
+ <section class="content-error">
516
+ <h1>Not found</h1>
517
+ <p>${message}</p>
518
+ <p><a href="/dev/content">← back to the content view</a></p>
519
+ </section>
520
+ </main>`;
521
+ return layout({
522
+ title: 'Not found — deskwork',
523
+ cssHrefs: [
524
+ '/static/css/editorial-review.css',
525
+ '/static/css/editorial-nav.css',
526
+ '/static/css/content.css',
527
+ ],
528
+ bodyAttrs: 'data-review-ui="studio"',
529
+ bodyHtml: body,
530
+ scriptModules: [],
531
+ });
532
+ }