heyiam 0.2.29 → 0.3.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 (177) hide show
  1. package/README.md +45 -0
  2. package/dist/config.js +10 -1
  3. package/dist/db.js +1 -2
  4. package/dist/export.js +40 -25
  5. package/dist/format-utils.js +5 -0
  6. package/dist/index.js +168 -0
  7. package/dist/mount.js +300 -102
  8. package/dist/parsers/claude.js +2 -28
  9. package/dist/parsers/codex.js +2 -26
  10. package/dist/parsers/cursor.js +2 -26
  11. package/dist/parsers/duration.js +35 -0
  12. package/dist/parsers/gemini.js +2 -20
  13. package/dist/parsers/types.js +0 -1
  14. package/dist/public/assets/index-BZ65TU_Y.js +40 -0
  15. package/dist/public/assets/index-CqCaW2cb.css +1 -0
  16. package/dist/public/index.html +2 -2
  17. package/dist/redact.js +4 -104
  18. package/dist/render/build-render-data.js +9 -2
  19. package/dist/render/index.js +32 -5
  20. package/dist/render/liquid.js +147 -7
  21. package/dist/render/mock-data.js +303 -0
  22. package/dist/render/templates/aurora/portfolio.liquid +204 -0
  23. package/dist/render/templates/aurora/project.liquid +260 -0
  24. package/dist/render/templates/aurora/session.liquid +223 -0
  25. package/dist/render/templates/aurora/styles.css +1178 -0
  26. package/dist/render/templates/bauhaus/portfolio.liquid +179 -0
  27. package/dist/render/templates/bauhaus/project.liquid +300 -0
  28. package/dist/render/templates/bauhaus/session.liquid +333 -0
  29. package/dist/render/templates/bauhaus/styles.css +1641 -0
  30. package/dist/render/templates/blueprint/portfolio.liquid +167 -0
  31. package/dist/render/templates/blueprint/project.liquid +286 -0
  32. package/dist/render/templates/blueprint/session.liquid +248 -0
  33. package/dist/render/templates/blueprint/styles.css +1285 -0
  34. package/dist/render/templates/canvas/portfolio.liquid +215 -0
  35. package/dist/render/templates/canvas/project.liquid +235 -0
  36. package/dist/render/templates/canvas/session.liquid +223 -0
  37. package/dist/render/templates/canvas/styles.css +1436 -0
  38. package/dist/render/templates/carbon/portfolio.liquid +170 -0
  39. package/dist/render/templates/carbon/project.liquid +249 -0
  40. package/dist/render/templates/carbon/session.liquid +190 -0
  41. package/dist/render/templates/carbon/styles.css +1091 -0
  42. package/dist/render/templates/chalk/portfolio.liquid +199 -0
  43. package/dist/render/templates/chalk/project.liquid +245 -0
  44. package/dist/render/templates/chalk/session.liquid +215 -0
  45. package/dist/render/templates/chalk/styles.css +1157 -0
  46. package/dist/render/templates/circuit/portfolio.liquid +162 -0
  47. package/dist/render/templates/circuit/project.liquid +247 -0
  48. package/dist/render/templates/circuit/session.liquid +205 -0
  49. package/dist/render/templates/circuit/styles.css +1403 -0
  50. package/dist/render/templates/cosmos/portfolio.liquid +232 -0
  51. package/dist/render/templates/cosmos/project.liquid +327 -0
  52. package/dist/render/templates/cosmos/session.liquid +239 -0
  53. package/dist/render/templates/cosmos/styles.css +1151 -0
  54. package/dist/render/templates/daylight/portfolio.liquid +217 -0
  55. package/dist/render/templates/daylight/project.liquid +229 -0
  56. package/dist/render/templates/daylight/session.liquid +219 -0
  57. package/dist/render/templates/daylight/styles.css +1311 -0
  58. package/dist/render/templates/editorial/portfolio.liquid +126 -0
  59. package/dist/render/templates/editorial/project.liquid +202 -0
  60. package/dist/render/templates/editorial/session.liquid +171 -0
  61. package/dist/render/templates/editorial/styles.css +822 -0
  62. package/dist/render/templates/ember/portfolio.liquid +318 -0
  63. package/dist/render/templates/ember/project.liquid +232 -0
  64. package/dist/render/templates/ember/session.liquid +202 -0
  65. package/dist/render/templates/ember/styles.css +1283 -0
  66. package/dist/render/templates/glacier/portfolio.liquid +271 -0
  67. package/dist/render/templates/glacier/project.liquid +288 -0
  68. package/dist/render/templates/glacier/session.liquid +217 -0
  69. package/dist/render/templates/glacier/styles.css +1200 -0
  70. package/dist/render/templates/grid/portfolio.liquid +265 -0
  71. package/dist/render/templates/grid/project.liquid +306 -0
  72. package/dist/render/templates/grid/session.liquid +260 -0
  73. package/dist/render/templates/grid/styles.css +1441 -0
  74. package/dist/render/templates/kinetic/portfolio.liquid +170 -0
  75. package/dist/render/templates/kinetic/project.liquid +242 -0
  76. package/dist/render/templates/kinetic/session.liquid +228 -0
  77. package/dist/render/templates/kinetic/styles.css +944 -0
  78. package/dist/render/templates/meridian/portfolio.liquid +255 -0
  79. package/dist/render/templates/meridian/project.liquid +376 -0
  80. package/dist/render/templates/meridian/session.liquid +298 -0
  81. package/dist/render/templates/meridian/styles.css +1369 -0
  82. package/dist/render/templates/minimal/portfolio.liquid +71 -0
  83. package/dist/render/templates/minimal/project.liquid +154 -0
  84. package/dist/render/templates/minimal/session.liquid +140 -0
  85. package/dist/render/templates/minimal/styles.css +525 -0
  86. package/dist/render/templates/mono/portfolio.liquid +291 -0
  87. package/dist/render/templates/mono/project.liquid +275 -0
  88. package/dist/render/templates/mono/session.liquid +276 -0
  89. package/dist/render/templates/mono/styles.css +1016 -0
  90. package/dist/render/templates/neon/portfolio.liquid +217 -0
  91. package/dist/render/templates/neon/project.liquid +225 -0
  92. package/dist/render/templates/neon/session.liquid +195 -0
  93. package/dist/render/templates/neon/styles.css +1265 -0
  94. package/dist/render/templates/noir/portfolio.liquid +137 -0
  95. package/dist/render/templates/noir/project.liquid +220 -0
  96. package/dist/render/templates/noir/session.liquid +241 -0
  97. package/dist/render/templates/noir/styles.css +1223 -0
  98. package/dist/render/templates/obsidian/portfolio.liquid +257 -0
  99. package/dist/render/templates/obsidian/project.liquid +280 -0
  100. package/dist/render/templates/obsidian/session.liquid +241 -0
  101. package/dist/render/templates/obsidian/styles.css +1401 -0
  102. package/dist/render/templates/paper/portfolio.liquid +267 -0
  103. package/dist/render/templates/paper/project.liquid +235 -0
  104. package/dist/render/templates/paper/session.liquid +271 -0
  105. package/dist/render/templates/paper/styles.css +1509 -0
  106. package/dist/render/templates/parallax/portfolio.liquid +305 -0
  107. package/dist/render/templates/parallax/project.liquid +275 -0
  108. package/dist/render/templates/parallax/session.liquid +295 -0
  109. package/dist/render/templates/parallax/styles.css +1874 -0
  110. package/dist/render/templates/parchment/portfolio.liquid +290 -0
  111. package/dist/render/templates/parchment/project.liquid +289 -0
  112. package/dist/render/templates/parchment/session.liquid +346 -0
  113. package/dist/render/templates/parchment/styles.css +1397 -0
  114. package/dist/render/templates/partials/_beats.liquid +16 -0
  115. package/dist/render/templates/partials/_breadcrumb.liquid +9 -0
  116. package/dist/render/templates/partials/_footer.liquid +7 -0
  117. package/dist/render/templates/partials/_growth-chart.liquid +7 -0
  118. package/dist/render/templates/partials/_key-decisions.liquid +20 -0
  119. package/dist/render/templates/partials/_links.liquid +16 -0
  120. package/dist/render/templates/partials/_narrative.liquid +8 -0
  121. package/dist/render/templates/partials/_phases.liquid +20 -0
  122. package/dist/render/templates/partials/_portfolio-header.liquid +20 -0
  123. package/dist/render/templates/partials/_portfolio-projects.liquid +16 -0
  124. package/dist/render/templates/partials/_portfolio-stats.liquid +19 -0
  125. package/dist/render/templates/partials/_qa.liquid +13 -0
  126. package/dist/render/templates/partials/_screenshot.liquid +15 -0
  127. package/dist/render/templates/partials/_session-cards.liquid +30 -0
  128. package/dist/render/templates/partials/_session-header.liquid +39 -0
  129. package/dist/render/templates/partials/_session-sidebar.liquid +30 -0
  130. package/dist/render/templates/partials/_skills.liquid +12 -0
  131. package/dist/render/templates/partials/_source-breakdown.liquid +22 -0
  132. package/dist/render/templates/partials/_stats.liquid +38 -0
  133. package/dist/render/templates/partials/_work-timeline.liquid +7 -0
  134. package/dist/render/templates/project.liquid +7 -4
  135. package/dist/render/templates/radar/portfolio.liquid +233 -0
  136. package/dist/render/templates/radar/project.liquid +278 -0
  137. package/dist/render/templates/radar/session.liquid +300 -0
  138. package/dist/render/templates/radar/styles.css +1049 -0
  139. package/dist/render/templates/showcase/portfolio.liquid +231 -0
  140. package/dist/render/templates/showcase/project.liquid +237 -0
  141. package/dist/render/templates/showcase/session.liquid +210 -0
  142. package/dist/render/templates/showcase/styles.css +1279 -0
  143. package/dist/render/templates/signal/portfolio.liquid +227 -0
  144. package/dist/render/templates/signal/project.liquid +278 -0
  145. package/dist/render/templates/signal/session.liquid +282 -0
  146. package/dist/render/templates/signal/styles.css +1395 -0
  147. package/dist/render/templates/strata/portfolio.liquid +192 -0
  148. package/dist/render/templates/strata/project.liquid +282 -0
  149. package/dist/render/templates/strata/session.liquid +261 -0
  150. package/dist/render/templates/strata/styles.css +1350 -0
  151. package/dist/render/templates/styles.css +1190 -0
  152. package/dist/render/templates/terminal/portfolio.liquid +118 -0
  153. package/dist/render/templates/terminal/project.liquid +161 -0
  154. package/dist/render/templates/terminal/session.liquid +145 -0
  155. package/dist/render/templates/terminal/styles.css +492 -0
  156. package/dist/render/templates/verdant/portfolio.liquid +333 -0
  157. package/dist/render/templates/verdant/project.liquid +309 -0
  158. package/dist/render/templates/verdant/session.liquid +237 -0
  159. package/dist/render/templates/verdant/styles.css +1257 -0
  160. package/dist/render/templates/zen/portfolio.liquid +136 -0
  161. package/dist/render/templates/zen/project.liquid +187 -0
  162. package/dist/render/templates/zen/session.liquid +203 -0
  163. package/dist/render/templates/zen/styles.css +1207 -0
  164. package/dist/render/templates.js +90 -0
  165. package/dist/routes/context.js +15 -10
  166. package/dist/routes/enhance.js +17 -40
  167. package/dist/routes/export.js +14 -4
  168. package/dist/routes/preview.js +480 -108
  169. package/dist/routes/projects.js +11 -19
  170. package/dist/routes/publish.js +15 -17
  171. package/dist/routes/settings.js +94 -1
  172. package/dist/routes/sse.js +9 -0
  173. package/dist/server.js +8 -2
  174. package/dist/settings.js +17 -9
  175. package/package.json +2 -4
  176. package/dist/public/assets/index-CC9G8EF1.js +0 -21
  177. package/dist/public/assets/index-Dalqz2mC.css +0 -1
@@ -3,15 +3,264 @@ import path from 'node:path';
3
3
  import { readFileSync, existsSync } from 'node:fs';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { getAuthToken } from '../auth.js';
6
- import { loadEnhancedData, loadProjectEnhanceResult } from '../settings.js';
6
+ import { loadEnhancedData, loadProjectEnhanceResult, getDefaultTemplate, getPortfolioProfile } from '../settings.js';
7
7
  import { SCREENSHOTS_DIR } from '../screenshot.js';
8
- import { renderProjectHtml, renderSessionHtml } from '../render/index.js';
9
- import { buildSessionRenderData, buildSessionCard, buildProjectRenderData } from '../render/build-render-data.js';
8
+ import { renderProjectHtml, renderSessionHtml, renderPortfolioHtml } from '../render/index.js';
9
+ import { getTemplateCss, isValidTemplate, getTemplateInfo } from '../render/templates.js';
10
+ import { getMockPortfolioData, getMockProjectData, getMockProjectArc, getMockFullSessions, getMockSessionData } from '../render/mock-data.js';
11
+ import { buildSessionRenderData, buildProjectRenderData } from '../render/build-render-data.js';
10
12
  import { buildAgentSummary } from './context.js';
11
13
  import { displayNameFromDir } from '../sync.js';
14
+ import { toSlug } from '../format-utils.js';
15
+ import { getSessionsByProject } from '../db.js';
12
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+ /**
18
+ * In-memory cache for expensive buildProjectPreviewData calls.
19
+ * Keyed by project param — the render data is template-agnostic,
20
+ * so we cache once and re-render with different templates cheaply.
21
+ * TTL: 30 seconds (long enough for template browser to load all iframes).
22
+ */
23
+ const previewDataCache = new Map();
24
+ const PREVIEW_CACHE_TTL = 30_000;
25
+ /** Clear the preview data cache. Exported for testing. */
26
+ export function clearPreviewCache() {
27
+ previewDataCache.clear();
28
+ }
29
+ /**
30
+ * Build project render data and enhance result from a project parameter.
31
+ * Shared between the full-page preview and the JSON render endpoint.
32
+ */
33
+ async function buildProjectPreviewData(ctx, projectParam, queryOverrides) {
34
+ // Check cache (only when no query overrides, which are rare)
35
+ if (!queryOverrides?.repoUrl && !queryOverrides?.projectUrl) {
36
+ const cached = previewDataCache.get(projectParam);
37
+ if (cached && Date.now() - cached.ts < PREVIEW_CACHE_TTL) {
38
+ return cached.data;
39
+ }
40
+ }
41
+ const rawProjects = await ctx.getProjects();
42
+ const rawProj = rawProjects.find((p) => p.name === projectParam || p.dirName === projectParam);
43
+ if (!rawProj) {
44
+ throw new ProjectNotFoundError(projectParam);
45
+ }
46
+ const proj = await ctx.getProjectWithStats(rawProj);
47
+ const cached = loadProjectEnhanceResult(proj.dirName);
48
+ const auth = getAuthToken();
49
+ const enhanceResult = cached?.result;
50
+ // ── Fast path: read ALL session data from SQLite (single query, no JSONL parsing) ──
51
+ const dbSessions = getSessionsByProject(ctx.db, rawProj.dirName);
52
+ const dbById = new Map(dbSessions.map((r) => [r.id, r]));
53
+ /** Convert a SQLite SessionRow + optional enhanced data into a SessionCard */
54
+ function rowToCard(row, sid) {
55
+ const enhanced = loadEnhancedData(sid);
56
+ const skills = enhanced?.skills ?? (row.skills ? JSON.parse(row.skills) : []);
57
+ const title = enhanced?.title ?? row.title ?? sid;
58
+ const devTake = (enhanced?.developerTake ?? '').slice(0, 2000);
59
+ const slug = toSlug(title, 80);
60
+ // Check for child sessions (subagents) in DB
61
+ const children = dbSessions.filter((r) => r.parent_session_id === sid);
62
+ let agentSummary;
63
+ if (children.length > 0) {
64
+ agentSummary = {
65
+ is_orchestrated: true,
66
+ agents: children.map((c) => ({
67
+ role: c.agent_role ?? 'agent',
68
+ duration_minutes: c.duration_minutes ?? 0,
69
+ loc_changed: (c.loc_added ?? 0) + (c.loc_removed ?? 0),
70
+ })),
71
+ };
72
+ }
73
+ return {
74
+ token: sid,
75
+ slug,
76
+ title,
77
+ devTake,
78
+ durationMinutes: row.duration_minutes ?? 0,
79
+ turns: row.turns ?? 0,
80
+ locChanged: (row.loc_added ?? 0) + (row.loc_removed ?? 0),
81
+ linesAdded: row.loc_added ?? 0,
82
+ linesDeleted: row.loc_removed ?? 0,
83
+ filesChanged: row.files_changed ?? 0,
84
+ skills,
85
+ recordedAt: row.start_time ?? new Date().toISOString(),
86
+ sourceTool: row.source ?? 'claude',
87
+ agentSummary,
88
+ };
89
+ }
90
+ // Build selected session cards from DB
91
+ const sessionCards = [];
92
+ if (cached?.selectedSessionIds) {
93
+ for (const sid of cached.selectedSessionIds) {
94
+ const row = dbById.get(sid);
95
+ if (!row)
96
+ continue;
97
+ sessionCards.push(rowToCard(row, sid));
98
+ }
99
+ }
100
+ // Build ALL session cards from DB (for work timeline + growth chart)
101
+ // Only parent sessions (not subagents) — subagents are included via agentSummary
102
+ const allSessionCards = [];
103
+ const sessionStatsMap = new Map();
104
+ for (const row of dbSessions) {
105
+ if (row.is_subagent)
106
+ continue;
107
+ const enhanced = loadEnhancedData(row.id);
108
+ const skills = enhanced?.skills ?? (row.skills ? JSON.parse(row.skills) : []);
109
+ sessionStatsMap.set(row.id, {
110
+ duration: row.duration_minutes ?? 0,
111
+ date: row.start_time || undefined,
112
+ skills,
113
+ description: enhanced?.context || '',
114
+ });
115
+ allSessionCards.push(rowToCard(row, row.id));
116
+ }
117
+ // Enrich timeline sessions with real stats
118
+ const enrichedTimeline = (enhanceResult?.timeline || []).map((period) => ({
119
+ period: period.period,
120
+ label: period.label,
121
+ sessions: period.sessions.map((s) => {
122
+ const stats = sessionStatsMap.get(s.sessionId);
123
+ return {
124
+ ...s,
125
+ duration: stats?.duration ?? 0,
126
+ date: stats?.date,
127
+ skills: stats?.skills,
128
+ description: stats?.description,
129
+ };
130
+ }),
131
+ }));
132
+ const projAny = proj;
133
+ const rawName = projAny.name || displayNameFromDir(projAny.dirName);
134
+ const title = cached?.title || rawName;
135
+ const slug = toSlug(rawName);
136
+ // Metadata from enhance cache (set in sidebar), with query overrides taking priority
137
+ const cachedAny = cached;
138
+ const metaRepoUrl = queryOverrides?.repoUrl || cachedAny?.repoUrl;
139
+ const metaProjectUrl = queryOverrides?.projectUrl || cachedAny?.projectUrl;
140
+ const renderData = buildProjectRenderData({
141
+ username: auth?.username || 'preview',
142
+ slug,
143
+ title,
144
+ narrative: enhanceResult?.narrative || projAny.description || '',
145
+ repoUrl: metaRepoUrl,
146
+ projectUrl: metaProjectUrl,
147
+ screenshotUrl: (() => {
148
+ return existsSync(path.join(SCREENSHOTS_DIR, `${slug}.png`))
149
+ ? `/screenshots/${slug}.png`
150
+ : undefined;
151
+ })(),
152
+ timeline: enrichedTimeline,
153
+ skills: enhanceResult?.skills || projAny.skills || [],
154
+ totalSessions: projAny.sessionCount,
155
+ totalLoc: projAny.totalLoc,
156
+ totalDurationMinutes: projAny.totalDuration,
157
+ totalAgentDurationMinutes: projAny.totalAgentDuration,
158
+ totalFilesChanged: projAny.totalFiles,
159
+ totalTokens: (projAny.totalInputTokens || 0) + (projAny.totalOutputTokens || 0) || undefined,
160
+ sessionCards,
161
+ allSessionCards,
162
+ sessionBaseUrl: `/preview/project/${encodeURIComponent(projectParam)}/session`,
163
+ });
164
+ const result = { renderData, enhanceResult, projName: projAny.name };
165
+ // Cache the result (template-agnostic data, re-rendered cheaply per template)
166
+ previewDataCache.set(projectParam, { data: result, ts: Date.now() });
167
+ return result;
168
+ }
169
+ /** Sentinel error for project-not-found so callers can return 404. */
170
+ class ProjectNotFoundError extends Error {
171
+ constructor(project) {
172
+ super(`Project not found: ${project}`);
173
+ this.name = 'ProjectNotFoundError';
174
+ }
175
+ }
13
176
  export function createPreviewRouter(ctx) {
14
177
  const router = Router();
178
+ // Serve template previews with mock data.
179
+ // Tries static mockup HTML first (fast), falls back to Liquid rendering.
180
+ router.get('/preview/template/:name', (req, res) => {
181
+ const name = String(req.params.name);
182
+ if (!isValidTemplate(name)) {
183
+ res.status(404).send('Template not found');
184
+ return;
185
+ }
186
+ const page = req.query.page || 'project';
187
+ // 1. Try static mockup HTML (instant, from docs/mockups/)
188
+ const mockupPath = path.resolve(__dirname, '..', '..', '..', 'docs', 'mockups', name, `${page}.html`);
189
+ if (existsSync(mockupPath)) {
190
+ let html = readFileSync(mockupPath, 'utf-8');
191
+ html = html.replace(/\.\.\/assets\//g, '/preview/template-assets/');
192
+ html = html.replace(/\.\/portfolio\.html/g, `/preview/template/${name}?page=portfolio`);
193
+ html = html.replace(/\.\/project\.html/g, `/preview/template/${name}?page=project`);
194
+ html = html.replace(/\.\/session\.html/g, `/preview/template/${name}?page=session`);
195
+ res.setHeader('Content-Type', 'text/html');
196
+ res.setHeader('Cache-Control', 'public, max-age=3600');
197
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN');
198
+ res.send(html);
199
+ return;
200
+ }
201
+ // 2. Fall back to Liquid rendering with mock data
202
+ try {
203
+ let bodyHtml;
204
+ if (page === 'portfolio') {
205
+ bodyHtml = renderPortfolioHtml(getMockPortfolioData(), name);
206
+ }
207
+ else if (page === 'session') {
208
+ bodyHtml = renderSessionHtml(getMockSessionData(), name);
209
+ }
210
+ else {
211
+ bodyHtml = renderProjectHtml(getMockProjectData(), { arc: getMockProjectArc(), fullSessions: getMockFullSessions() }, name);
212
+ }
213
+ const css = getTemplateCss(name);
214
+ const fullHtml = `<!DOCTYPE html>
215
+ <html lang="en">
216
+ <head>
217
+ <meta charset="UTF-8">
218
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
219
+ <meta name="heyiam-api-base" content="/api" />
220
+ <title>${name} — ${page} preview</title>
221
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
222
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
223
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&family=Newsreader:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet" />
224
+ <style>${css}
225
+ /* Preview override */
226
+ body { overflow: auto !important; min-height: auto !important; }
227
+ #root { min-height: auto !important; }
228
+ </style>
229
+ </head>
230
+ <body>
231
+ ${bodyHtml}
232
+ <script src="/heyiam-mount.js"></script>
233
+ </body>
234
+ </html>`;
235
+ res.setHeader('Content-Type', 'text/html');
236
+ res.setHeader('Cache-Control', 'public, max-age=3600');
237
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN');
238
+ res.send(fullHtml);
239
+ }
240
+ catch (err) {
241
+ console.error(`[preview/template] Liquid render failed for ${name}/${page}:`, err.message);
242
+ res.status(500).send('Template render failed');
243
+ }
244
+ });
245
+ // Serve mockup assets (headshots, etc.)
246
+ router.get('/preview/template-assets/:filename', (req, res) => {
247
+ const filename = String(req.params.filename);
248
+ // Only allow expected image files
249
+ if (!/^[\w-]+\.(jpg|png)$/.test(filename)) {
250
+ res.status(400).end();
251
+ return;
252
+ }
253
+ const assetPath = path.resolve(__dirname, '..', '..', '..', 'docs', 'mockups', 'assets', filename);
254
+ if (existsSync(assetPath)) {
255
+ const ext = filename.endsWith('.png') ? 'image/png' : 'image/jpeg';
256
+ res.setHeader('Content-Type', ext);
257
+ res.setHeader('Cache-Control', 'public, max-age=86400');
258
+ res.send(readFileSync(assetPath));
259
+ }
260
+ else {
261
+ res.status(404).end();
262
+ }
263
+ });
15
264
  // Serve local screenshot files
16
265
  router.get('/screenshots/:slug.png', (req, res) => {
17
266
  const filePath = path.join(SCREENSHOTS_DIR, `${req.params.slug}.png`);
@@ -24,120 +273,94 @@ export function createPreviewRouter(ctx) {
24
273
  res.status(404).end();
25
274
  }
26
275
  });
276
+ // Delete a screenshot file
277
+ router.delete('/api/projects/:project/screenshot', (req, res) => {
278
+ const projectParam = String(req.params.project);
279
+ const slug = toSlug(projectParam);
280
+ const filePath = path.join(SCREENSHOTS_DIR, `${slug}.png`);
281
+ try {
282
+ if (existsSync(filePath)) {
283
+ const { unlinkSync } = require('node:fs');
284
+ unlinkSync(filePath);
285
+ }
286
+ res.json({ ok: true });
287
+ }
288
+ catch {
289
+ res.status(500).json({ error: 'Failed to delete screenshot' });
290
+ }
291
+ });
27
292
  // Project preview -- serves full standalone HTML page identical to heyi.am
28
293
  router.get('/preview/project/:project', async (req, res) => {
29
294
  try {
30
295
  const projectParam = String(req.params.project);
31
- const rawProjects = await ctx.getProjects();
32
- const rawProj = rawProjects.find((p) => p.name === projectParam || p.dirName === projectParam);
33
- if (!rawProj) {
296
+ const templateOverride = req.query.template;
297
+ const { renderData, enhanceResult, projName } = await buildProjectPreviewData(ctx, projectParam, {
298
+ repoUrl: req.query.repoUrl,
299
+ projectUrl: req.query.projectUrl,
300
+ });
301
+ // Use template override if valid, otherwise fall back to user default
302
+ const templateName = (templateOverride && isValidTemplate(templateOverride))
303
+ ? templateOverride
304
+ : (getDefaultTemplate() || 'editorial');
305
+ let bodyHtml;
306
+ try {
307
+ bodyHtml = renderProjectHtml(renderData, { arc: enhanceResult?.arc }, templateName);
308
+ }
309
+ catch {
310
+ bodyHtml = renderProjectHtml(renderData, { arc: enhanceResult?.arc }, 'editorial');
311
+ }
312
+ res.type('html').send(ctx.buildPreviewPage(projName, bodyHtml, 'PREVIEW — this is how your project will appear on heyi.am', templateName));
313
+ }
314
+ catch (err) {
315
+ if (err instanceof ProjectNotFoundError) {
34
316
  res.status(404).send('Project not found');
35
317
  return;
36
318
  }
37
- const proj = await ctx.getProjectWithStats(rawProj);
38
- const cached = loadProjectEnhanceResult(proj.dirName);
39
- const auth = getAuthToken();
40
- // Build session cards from parsed sessions + enhanced data
41
- const sessionCards = [];
42
- if (cached?.selectedSessionIds) {
43
- for (const sid of cached.selectedSessionIds) {
44
- const meta = rawProj.sessions.find((s) => s.sessionId === sid);
45
- if (!meta)
46
- continue;
47
- try {
48
- const session = await ctx.loadSession(meta.path, rawProj.name, sid);
49
- const enhanced = loadEnhancedData(sid);
50
- const agentSummary = await buildAgentSummary(meta.children ?? [], (c) => ctx.getSessionStats(c, rawProj.name), { deduplicate: true });
51
- sessionCards.push(buildSessionCard({
52
- sessionId: sid,
53
- session,
54
- enhanced,
55
- username: auth?.username || 'preview',
56
- projectSlug: proj.dirName,
57
- sessionSlug: sid,
58
- sourceTool: session.source || 'claude',
59
- agentSummary,
60
- }));
61
- }
62
- catch { /* skip sessions that fail to parse */ }
63
- }
319
+ console.error('[preview] Error:', err.message);
320
+ res.status(500).send('Preview rendering failed');
321
+ }
322
+ });
323
+ // JSON render endpoint -- returns rendered HTML fragment + CSS for embedding in React UI
324
+ router.get('/api/projects/:project/render', async (req, res) => {
325
+ try {
326
+ const projectParam = String(req.params.project);
327
+ const templateOverride = req.query.template;
328
+ const { renderData, enhanceResult } = await buildProjectPreviewData(ctx, projectParam, {
329
+ repoUrl: req.query.repoUrl,
330
+ projectUrl: req.query.projectUrl,
331
+ });
332
+ // Override sessionBaseUrl so Liquid generates SPA-friendly /session/:id links
333
+ // (the cached renderData uses /preview/project/... URLs for the standalone preview)
334
+ const spaRenderData = { ...renderData, sessionBaseUrl: '/session' };
335
+ // Use template override if valid, otherwise fall back to user default
336
+ let templateName = (templateOverride && isValidTemplate(templateOverride))
337
+ ? templateOverride
338
+ : (getDefaultTemplate() || 'editorial');
339
+ let html;
340
+ try {
341
+ html = renderProjectHtml(spaRenderData, { arc: enhanceResult?.arc }, templateName);
64
342
  }
65
- const enhanceResult = cached?.result;
66
- // Build cards for ALL sessions (for work timeline + growth chart)
67
- const allSessionCards = [];
68
- const sessionStatsMap = new Map();
69
- for (const meta of rawProj.sessions) {
70
- try {
71
- const s = await ctx.loadSession(meta.path, rawProj.name, meta.sessionId);
72
- const enhanced = loadEnhancedData(meta.sessionId);
73
- sessionStatsMap.set(meta.sessionId, {
74
- duration: s.durationMinutes ?? 0,
75
- date: s.date || undefined,
76
- skills: enhanced?.skills ?? s.skills ?? [],
77
- description: enhanced?.context || '',
78
- });
79
- const allAgentSummary = await buildAgentSummary(meta.children ?? [], (c) => ctx.getSessionStats(c, rawProj.name));
80
- allSessionCards.push(buildSessionCard({
81
- sessionId: meta.sessionId,
82
- session: s,
83
- enhanced,
84
- username: auth?.username || 'preview',
85
- projectSlug: proj.dirName,
86
- sessionSlug: meta.sessionId,
87
- sourceTool: s.source || 'claude',
88
- agentSummary: allAgentSummary,
89
- }));
90
- }
91
- catch { /* skip */ }
92
- }
93
- // Enrich timeline sessions with real stats
94
- const enrichedTimeline = (enhanceResult?.timeline || []).map((period) => ({
95
- period: period.period,
96
- label: period.label,
97
- sessions: period.sessions.map((s) => {
98
- const stats = sessionStatsMap.get(s.sessionId);
99
- return {
100
- ...s,
101
- duration: stats?.duration ?? 0,
102
- date: stats?.date,
103
- skills: stats?.skills,
104
- description: stats?.description,
105
- };
106
- }),
107
- }));
108
- const projAny = proj;
109
- const name = projAny.name || displayNameFromDir(projAny.dirName);
110
- const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
111
- const renderData = buildProjectRenderData({
112
- username: auth?.username || 'preview',
113
- slug,
114
- title: name,
115
- narrative: enhanceResult?.narrative || projAny.description || '',
116
- repoUrl: req.query.repoUrl || undefined,
117
- projectUrl: req.query.projectUrl || undefined,
118
- screenshotUrl: (() => {
119
- return existsSync(path.join(SCREENSHOTS_DIR, `${slug}.png`))
120
- ? `/screenshots/${slug}.png`
121
- : undefined;
122
- })(),
123
- timeline: enrichedTimeline,
124
- skills: enhanceResult?.skills || projAny.skills || [],
125
- totalSessions: projAny.sessionCount,
126
- totalLoc: projAny.totalLoc,
127
- totalDurationMinutes: projAny.totalDuration,
128
- totalAgentDurationMinutes: projAny.totalAgentDuration,
129
- totalFilesChanged: projAny.totalFiles,
130
- totalTokens: (projAny.totalInputTokens || 0) + (projAny.totalOutputTokens || 0) || undefined,
131
- sessionCards,
132
- allSessionCards,
133
- sessionBaseUrl: `/preview/project/${encodeURIComponent(projectParam)}/session`,
343
+ catch {
344
+ // Template files may not exist yet (e.g. showcase) -- fall back to editorial
345
+ templateName = 'editorial';
346
+ html = renderProjectHtml(spaRenderData, { arc: enhanceResult?.arc }, templateName);
347
+ }
348
+ const css = getTemplateCss(templateName);
349
+ const screenshotUrl = spaRenderData.project.screenshotUrl || undefined;
350
+ const templateInfo = getTemplateInfo(templateName);
351
+ res.json({
352
+ html, css, template: templateName, screenshotUrl,
353
+ accent: templateInfo?.accent ?? '#084471',
354
+ mode: templateInfo?.mode ?? 'light',
134
355
  });
135
- const bodyHtml = renderProjectHtml(renderData);
136
- res.type('html').send(ctx.buildPreviewPage(projAny.name, bodyHtml, 'PREVIEW — this is how your project will appear on heyi.am'));
137
356
  }
138
357
  catch (err) {
139
- console.error('[preview] Error:', err.message);
140
- res.status(500).send('Preview rendering failed');
358
+ if (err instanceof ProjectNotFoundError) {
359
+ res.status(404).json({ error: 'Project not found' });
360
+ return;
361
+ }
362
+ console.error('[api/render] Error:', err.message);
363
+ res.status(500).json({ error: 'Render failed' });
141
364
  }
142
365
  });
143
366
  // Session preview
@@ -160,6 +383,9 @@ export function createPreviewRouter(ctx) {
160
383
  const session = await ctx.loadSession(meta.path, rawProj.name, sessionId);
161
384
  const enhanced = loadEnhancedData(sessionId);
162
385
  const agentSummary = await buildAgentSummary(meta.children ?? [], (c) => ctx.getSessionStats(c, rawProj.name));
386
+ const templateName = req.query.template && isValidTemplate(req.query.template)
387
+ ? req.query.template
388
+ : (getDefaultTemplate() || 'editorial');
163
389
  const renderData = buildSessionRenderData({
164
390
  sessionId,
165
391
  session,
@@ -169,15 +395,161 @@ export function createPreviewRouter(ctx) {
169
395
  sessionSlug: sessionId,
170
396
  sourceTool: session.source || 'claude',
171
397
  agentSummary,
398
+ template: templateName,
172
399
  });
173
- const bodyHtml = renderSessionHtml(renderData);
174
- res.type('html').send(ctx.buildPreviewPage(session.title || sessionId, bodyHtml, 'PREVIEW — this is how your session will appear on heyi.am'));
400
+ const bodyHtml = renderSessionHtml(renderData, templateName);
401
+ res.type('html').send(ctx.buildPreviewPage(session.title || sessionId, bodyHtml, 'PREVIEW — this is how your session will appear on heyi.am', templateName));
175
402
  }
176
403
  catch (err) {
177
404
  console.error('[session-preview] Error:', err.message);
178
405
  res.status(500).send('Session preview failed');
179
406
  }
180
407
  });
408
+ // JSON render endpoint for sessions — returns HTML fragment + CSS for embedding in React UI
409
+ router.get('/api/sessions/:sessionId/render', async (req, res) => {
410
+ try {
411
+ const sessionId = String(req.params.sessionId);
412
+ const templateOverride = req.query.template;
413
+ // Find the session across all projects
414
+ const rawProjects = await ctx.getProjects();
415
+ let foundMeta;
416
+ let foundProj;
417
+ for (const proj of rawProjects) {
418
+ const meta = proj.sessions.find((s) => s.sessionId === sessionId);
419
+ if (meta) {
420
+ foundMeta = meta;
421
+ foundProj = proj;
422
+ break;
423
+ }
424
+ }
425
+ if (!foundMeta || !foundProj) {
426
+ res.status(404).json({ error: 'Session not found' });
427
+ return;
428
+ }
429
+ const auth = getAuthToken();
430
+ const session = await ctx.loadSession(foundMeta.path, foundProj.name, sessionId);
431
+ const enhanced = loadEnhancedData(sessionId);
432
+ const agentSummary = await buildAgentSummary(foundMeta.children ?? [], (c) => ctx.getSessionStats(c, foundProj.name));
433
+ let templateName = (templateOverride && isValidTemplate(templateOverride))
434
+ ? templateOverride
435
+ : (getDefaultTemplate() || 'editorial');
436
+ const renderData = buildSessionRenderData({
437
+ sessionId,
438
+ session,
439
+ enhanced,
440
+ username: auth?.username || 'preview',
441
+ projectSlug: foundProj.dirName,
442
+ sessionSlug: sessionId,
443
+ sourceTool: session.source || 'claude',
444
+ agentSummary,
445
+ template: templateName,
446
+ });
447
+ let html;
448
+ try {
449
+ html = renderSessionHtml(renderData, templateName);
450
+ }
451
+ catch {
452
+ templateName = 'editorial';
453
+ html = renderSessionHtml(renderData, templateName);
454
+ }
455
+ const css = getTemplateCss(templateName);
456
+ const templateInfo = getTemplateInfo(templateName);
457
+ res.json({
458
+ html, css, template: templateName,
459
+ accent: templateInfo?.accent ?? '#084471',
460
+ mode: templateInfo?.mode ?? 'light',
461
+ });
462
+ }
463
+ catch (err) {
464
+ console.error('[api/session/render] Error:', err.message);
465
+ res.status(500).json({ error: 'Session render failed' });
466
+ }
467
+ });
468
+ // Portfolio preview -- serves full standalone HTML page with real user data
469
+ router.get('/preview/portfolio', async (_req, res) => {
470
+ try {
471
+ const profile = getPortfolioProfile();
472
+ const auth = getAuthToken();
473
+ const templateName = getDefaultTemplate() || 'editorial';
474
+ // Build portfolio projects from real project data
475
+ const rawProjects = await ctx.getProjects();
476
+ const portfolioProjects = [];
477
+ let totalDuration = 0;
478
+ let totalAgentDuration = 0;
479
+ let totalLoc = 0;
480
+ let totalSessions = 0;
481
+ for (const rawProj of rawProjects) {
482
+ try {
483
+ const proj = await ctx.getProjectWithStats(rawProj);
484
+ const cached = loadProjectEnhanceResult(rawProj.dirName);
485
+ const projDuration = proj.totalDuration || 0;
486
+ const projAgentDuration = proj.totalAgentDuration || 0;
487
+ const projLoc = proj.totalLoc || 0;
488
+ const projSessions = proj.sessionCount || 0;
489
+ totalDuration += projDuration;
490
+ totalAgentDuration += projAgentDuration;
491
+ totalLoc += projLoc;
492
+ totalSessions += projSessions;
493
+ const title = cached?.title
494
+ || proj.name || displayNameFromDir(rawProj.dirName);
495
+ // Session activity for charts
496
+ const dbSessions = getSessionsByProject(ctx.db, rawProj.dirName);
497
+ const sessionActivity = dbSessions
498
+ .filter(s => !s.is_subagent)
499
+ .map(s => ({
500
+ date: s.start_time || '',
501
+ loc: (s.loc_added || 0) + (s.loc_removed || 0),
502
+ durationMinutes: s.duration_minutes || 0,
503
+ }));
504
+ portfolioProjects.push({
505
+ slug: toSlug(rawProj.dirName),
506
+ title,
507
+ narrative: cached?.result?.narrative || proj.description || '',
508
+ totalSessions: projSessions,
509
+ totalLoc: projLoc,
510
+ totalDurationMinutes: projDuration,
511
+ totalAgentDurationMinutes: projAgentDuration,
512
+ totalFilesChanged: proj.totalFiles || 0,
513
+ skills: cached?.result?.skills || proj.skills || [],
514
+ publishedCount: 0,
515
+ sessions: sessionActivity,
516
+ });
517
+ }
518
+ catch { /* skip projects that fail */ }
519
+ }
520
+ // Always use real project data; fall back gracefully for missing profile fields
521
+ const username = auth?.username || 'preview';
522
+ const renderData = {
523
+ user: {
524
+ username,
525
+ accent: '#084471',
526
+ displayName: profile.displayName || '',
527
+ bio: profile.bio || '',
528
+ location: profile.location || '',
529
+ status: 'active',
530
+ email: profile.email,
531
+ phone: profile.phone,
532
+ photoUrl: profile.photoBase64 || undefined,
533
+ linkedinUrl: profile.linkedinUrl,
534
+ githubUrl: profile.githubUrl,
535
+ twitterHandle: profile.twitterHandle,
536
+ websiteUrl: profile.websiteUrl,
537
+ resumeUrl: profile.resumeBase64 ? '#' : undefined,
538
+ },
539
+ projects: portfolioProjects,
540
+ totalDurationMinutes: totalDuration,
541
+ totalAgentDurationMinutes: totalAgentDuration || undefined,
542
+ totalLoc,
543
+ totalSessions,
544
+ };
545
+ const bodyHtml = renderPortfolioHtml(renderData, templateName);
546
+ res.type('html').send(ctx.buildPreviewPage(renderData.user.displayName ? `${renderData.user.displayName}'s Portfolio` : 'Portfolio Preview', bodyHtml, 'PREVIEW — this is how your portfolio will appear on heyi.am', templateName));
547
+ }
548
+ catch (err) {
549
+ console.error('[portfolio-preview] Error:', err.message);
550
+ res.status(500).send('Portfolio preview failed');
551
+ }
552
+ });
181
553
  // Serve @heyiam/ui mount script for preview pages
182
554
  router.get('/heyiam-mount.js', (_req, res) => {
183
555
  // In built dist: dist/mount.js (copied during build)