heyiam 0.2.29 → 0.3.1

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 (186) hide show
  1. package/README.md +45 -0
  2. package/dist/auth.js +29 -3
  3. package/dist/config.js +10 -1
  4. package/dist/db.js +0 -1
  5. package/dist/export.js +124 -27
  6. package/dist/format-utils.js +5 -0
  7. package/dist/github.js +381 -0
  8. package/dist/index.js +168 -0
  9. package/dist/mount.js +300 -102
  10. package/dist/parsers/claude.js +2 -28
  11. package/dist/parsers/codex.js +2 -26
  12. package/dist/parsers/cursor.js +2 -26
  13. package/dist/parsers/duration.js +35 -0
  14. package/dist/parsers/gemini.js +2 -20
  15. package/dist/parsers/index.js +22 -3
  16. package/dist/parsers/types.js +0 -1
  17. package/dist/public/assets/index-Coilyhtr.css +1 -0
  18. package/dist/public/assets/index-D0noVMFu.js +44 -0
  19. package/dist/public/index.html +2 -2
  20. package/dist/redact.js +4 -104
  21. package/dist/render/build-render-data.js +9 -2
  22. package/dist/render/index.js +32 -5
  23. package/dist/render/liquid.js +147 -7
  24. package/dist/render/mock-data.js +303 -0
  25. package/dist/render/templates/aurora/portfolio.liquid +192 -0
  26. package/dist/render/templates/aurora/project.liquid +260 -0
  27. package/dist/render/templates/aurora/session.liquid +223 -0
  28. package/dist/render/templates/aurora/styles.css +1184 -0
  29. package/dist/render/templates/bauhaus/portfolio.liquid +169 -0
  30. package/dist/render/templates/bauhaus/project.liquid +300 -0
  31. package/dist/render/templates/bauhaus/session.liquid +333 -0
  32. package/dist/render/templates/bauhaus/styles.css +1645 -0
  33. package/dist/render/templates/blueprint/portfolio.liquid +153 -0
  34. package/dist/render/templates/blueprint/project.liquid +286 -0
  35. package/dist/render/templates/blueprint/session.liquid +248 -0
  36. package/dist/render/templates/blueprint/styles.css +1289 -0
  37. package/dist/render/templates/canvas/portfolio.liquid +203 -0
  38. package/dist/render/templates/canvas/project.liquid +235 -0
  39. package/dist/render/templates/canvas/session.liquid +223 -0
  40. package/dist/render/templates/canvas/styles.css +1440 -0
  41. package/dist/render/templates/carbon/portfolio.liquid +160 -0
  42. package/dist/render/templates/carbon/project.liquid +249 -0
  43. package/dist/render/templates/carbon/session.liquid +190 -0
  44. package/dist/render/templates/carbon/styles.css +1097 -0
  45. package/dist/render/templates/chalk/portfolio.liquid +189 -0
  46. package/dist/render/templates/chalk/project.liquid +245 -0
  47. package/dist/render/templates/chalk/session.liquid +215 -0
  48. package/dist/render/templates/chalk/styles.css +1161 -0
  49. package/dist/render/templates/circuit/portfolio.liquid +152 -0
  50. package/dist/render/templates/circuit/project.liquid +247 -0
  51. package/dist/render/templates/circuit/session.liquid +205 -0
  52. package/dist/render/templates/circuit/styles.css +1409 -0
  53. package/dist/render/templates/cosmos/portfolio.liquid +222 -0
  54. package/dist/render/templates/cosmos/project.liquid +327 -0
  55. package/dist/render/templates/cosmos/session.liquid +239 -0
  56. package/dist/render/templates/cosmos/styles.css +1157 -0
  57. package/dist/render/templates/daylight/portfolio.liquid +207 -0
  58. package/dist/render/templates/daylight/project.liquid +229 -0
  59. package/dist/render/templates/daylight/session.liquid +219 -0
  60. package/dist/render/templates/daylight/styles.css +1315 -0
  61. package/dist/render/templates/editorial/portfolio.liquid +110 -0
  62. package/dist/render/templates/editorial/project.liquid +202 -0
  63. package/dist/render/templates/editorial/session.liquid +171 -0
  64. package/dist/render/templates/editorial/styles.css +826 -0
  65. package/dist/render/templates/ember/portfolio.liquid +306 -0
  66. package/dist/render/templates/ember/project.liquid +232 -0
  67. package/dist/render/templates/ember/session.liquid +202 -0
  68. package/dist/render/templates/ember/styles.css +1289 -0
  69. package/dist/render/templates/glacier/portfolio.liquid +261 -0
  70. package/dist/render/templates/glacier/project.liquid +288 -0
  71. package/dist/render/templates/glacier/session.liquid +217 -0
  72. package/dist/render/templates/glacier/styles.css +1204 -0
  73. package/dist/render/templates/grid/portfolio.liquid +255 -0
  74. package/dist/render/templates/grid/project.liquid +306 -0
  75. package/dist/render/templates/grid/session.liquid +260 -0
  76. package/dist/render/templates/grid/styles.css +1445 -0
  77. package/dist/render/templates/kinetic/portfolio.liquid +158 -0
  78. package/dist/render/templates/kinetic/project.liquid +242 -0
  79. package/dist/render/templates/kinetic/session.liquid +228 -0
  80. package/dist/render/templates/kinetic/styles.css +948 -0
  81. package/dist/render/templates/meridian/portfolio.liquid +243 -0
  82. package/dist/render/templates/meridian/project.liquid +376 -0
  83. package/dist/render/templates/meridian/session.liquid +298 -0
  84. package/dist/render/templates/meridian/styles.css +1375 -0
  85. package/dist/render/templates/minimal/portfolio.liquid +71 -0
  86. package/dist/render/templates/minimal/project.liquid +154 -0
  87. package/dist/render/templates/minimal/session.liquid +140 -0
  88. package/dist/render/templates/minimal/styles.css +529 -0
  89. package/dist/render/templates/mono/portfolio.liquid +281 -0
  90. package/dist/render/templates/mono/project.liquid +275 -0
  91. package/dist/render/templates/mono/session.liquid +276 -0
  92. package/dist/render/templates/mono/styles.css +1022 -0
  93. package/dist/render/templates/neon/portfolio.liquid +207 -0
  94. package/dist/render/templates/neon/project.liquid +225 -0
  95. package/dist/render/templates/neon/session.liquid +195 -0
  96. package/dist/render/templates/neon/styles.css +1271 -0
  97. package/dist/render/templates/noir/portfolio.liquid +137 -0
  98. package/dist/render/templates/noir/project.liquid +220 -0
  99. package/dist/render/templates/noir/session.liquid +241 -0
  100. package/dist/render/templates/noir/styles.css +1229 -0
  101. package/dist/render/templates/obsidian/portfolio.liquid +247 -0
  102. package/dist/render/templates/obsidian/project.liquid +280 -0
  103. package/dist/render/templates/obsidian/session.liquid +241 -0
  104. package/dist/render/templates/obsidian/styles.css +1407 -0
  105. package/dist/render/templates/paper/portfolio.liquid +257 -0
  106. package/dist/render/templates/paper/project.liquid +235 -0
  107. package/dist/render/templates/paper/session.liquid +271 -0
  108. package/dist/render/templates/paper/styles.css +1513 -0
  109. package/dist/render/templates/parallax/portfolio.liquid +295 -0
  110. package/dist/render/templates/parallax/project.liquid +275 -0
  111. package/dist/render/templates/parallax/session.liquid +295 -0
  112. package/dist/render/templates/parallax/styles.css +1880 -0
  113. package/dist/render/templates/parchment/portfolio.liquid +280 -0
  114. package/dist/render/templates/parchment/project.liquid +289 -0
  115. package/dist/render/templates/parchment/session.liquid +346 -0
  116. package/dist/render/templates/parchment/styles.css +1401 -0
  117. package/dist/render/templates/partials/_beats.liquid +16 -0
  118. package/dist/render/templates/partials/_breadcrumb.liquid +9 -0
  119. package/dist/render/templates/partials/_footer.liquid +7 -0
  120. package/dist/render/templates/partials/_growth-chart.liquid +7 -0
  121. package/dist/render/templates/partials/_key-decisions.liquid +20 -0
  122. package/dist/render/templates/partials/_links.liquid +16 -0
  123. package/dist/render/templates/partials/_narrative.liquid +8 -0
  124. package/dist/render/templates/partials/_phases.liquid +20 -0
  125. package/dist/render/templates/partials/_portfolio-header.liquid +20 -0
  126. package/dist/render/templates/partials/_portfolio-projects.liquid +16 -0
  127. package/dist/render/templates/partials/_portfolio-stats.liquid +19 -0
  128. package/dist/render/templates/partials/_qa.liquid +13 -0
  129. package/dist/render/templates/partials/_screenshot.liquid +15 -0
  130. package/dist/render/templates/partials/_session-cards.liquid +30 -0
  131. package/dist/render/templates/partials/_session-header.liquid +39 -0
  132. package/dist/render/templates/partials/_session-sidebar.liquid +30 -0
  133. package/dist/render/templates/partials/_skills.liquid +12 -0
  134. package/dist/render/templates/partials/_source-breakdown.liquid +22 -0
  135. package/dist/render/templates/partials/_stats.liquid +38 -0
  136. package/dist/render/templates/partials/_work-timeline.liquid +7 -0
  137. package/dist/render/templates/project.liquid +7 -4
  138. package/dist/render/templates/radar/portfolio.liquid +223 -0
  139. package/dist/render/templates/radar/project.liquid +278 -0
  140. package/dist/render/templates/radar/session.liquid +300 -0
  141. package/dist/render/templates/radar/styles.css +1055 -0
  142. package/dist/render/templates/showcase/portfolio.liquid +221 -0
  143. package/dist/render/templates/showcase/project.liquid +237 -0
  144. package/dist/render/templates/showcase/session.liquid +210 -0
  145. package/dist/render/templates/showcase/styles.css +1284 -0
  146. package/dist/render/templates/signal/portfolio.liquid +217 -0
  147. package/dist/render/templates/signal/project.liquid +278 -0
  148. package/dist/render/templates/signal/session.liquid +282 -0
  149. package/dist/render/templates/signal/styles.css +1401 -0
  150. package/dist/render/templates/strata/portfolio.liquid +180 -0
  151. package/dist/render/templates/strata/project.liquid +282 -0
  152. package/dist/render/templates/strata/session.liquid +261 -0
  153. package/dist/render/templates/strata/styles.css +1354 -0
  154. package/dist/render/templates/styles.css +1190 -0
  155. package/dist/render/templates/terminal/portfolio.liquid +102 -0
  156. package/dist/render/templates/terminal/project.liquid +161 -0
  157. package/dist/render/templates/terminal/session.liquid +145 -0
  158. package/dist/render/templates/terminal/styles.css +497 -0
  159. package/dist/render/templates/verdant/portfolio.liquid +321 -0
  160. package/dist/render/templates/verdant/project.liquid +309 -0
  161. package/dist/render/templates/verdant/session.liquid +237 -0
  162. package/dist/render/templates/verdant/styles.css +1261 -0
  163. package/dist/render/templates/zen/portfolio.liquid +124 -0
  164. package/dist/render/templates/zen/project.liquid +187 -0
  165. package/dist/render/templates/zen/session.liquid +203 -0
  166. package/dist/render/templates/zen/styles.css +1211 -0
  167. package/dist/render/templates.js +90 -0
  168. package/dist/routes/auth.js +7 -3
  169. package/dist/routes/context.js +17 -10
  170. package/dist/routes/delete.js +195 -0
  171. package/dist/routes/enhance.js +57 -40
  172. package/dist/routes/export.js +14 -4
  173. package/dist/routes/github.js +254 -0
  174. package/dist/routes/index.js +2 -0
  175. package/dist/routes/portfolio-render-data.js +160 -0
  176. package/dist/routes/preview.js +555 -108
  177. package/dist/routes/projects.js +61 -24
  178. package/dist/routes/publish.js +320 -31
  179. package/dist/routes/settings.js +194 -1
  180. package/dist/routes/sse.js +9 -0
  181. package/dist/search.js +6 -0
  182. package/dist/server.js +11 -3
  183. package/dist/settings.js +112 -9
  184. package/package.json +3 -4
  185. package/dist/public/assets/index-CC9G8EF1.js +0 -21
  186. package/dist/public/assets/index-Dalqz2mC.css +0 -1
@@ -3,15 +3,309 @@ 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, getAllProjectStats } from '../db.js';
16
+ import { applyPortfolioProjectFilter } from './portfolio-render-data.js';
12
17
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
+ /**
19
+ * In-memory cache for expensive buildProjectPreviewData calls.
20
+ * Keyed by project param — the render data is template-agnostic,
21
+ * so we cache once and re-render with different templates cheaply.
22
+ * TTL: 30 seconds (long enough for template browser to load all iframes).
23
+ */
24
+ const previewDataCache = new Map();
25
+ const PREVIEW_CACHE_TTL = 30_000;
26
+ /** Clear the preview data cache. Exported for testing. */
27
+ export function clearPreviewCache() {
28
+ previewDataCache.clear();
29
+ portfolioPreviewCache.clear();
30
+ }
31
+ /**
32
+ * In-memory cache for the rendered /preview/portfolio HTML response.
33
+ * Keyed by the literal string 'portfolio' (CLI is single-user). Cached value
34
+ * is the full HTML body that the route would otherwise re-render on every
35
+ * iframe reload from the React PreviewPane.
36
+ *
37
+ * TTL: 30 seconds. Invalidated explicitly on profile save, portfolio
38
+ * publish, and project mutations via invalidatePortfolioPreviewCache().
39
+ */
40
+ // Cache key is `portfolio:<templateName>` so previewing the user's data
41
+ // through alternate templates (template browser "My data" toggle) does not
42
+ // poison the default-template entry.
43
+ const portfolioPreviewCache = new Map();
44
+ const PORTFOLIO_PREVIEW_CACHE_TTL = 30_000;
45
+ function portfolioCacheKey(templateName) {
46
+ return `portfolio:${templateName}`;
47
+ }
48
+ /**
49
+ * Invalidate the cached /preview/portfolio HTML. Call this from any route
50
+ * that mutates state visible in the portfolio render: profile save,
51
+ * portfolio publish, project enhance-save, screenshot capture/delete, etc.
52
+ */
53
+ export function invalidatePortfolioPreviewCache() {
54
+ portfolioPreviewCache.clear();
55
+ }
56
+ /** Test helper: read current cache entry without mutating. */
57
+ export function _getPortfolioPreviewCacheEntry(templateName = 'editorial') {
58
+ return portfolioPreviewCache.get(portfolioCacheKey(templateName));
59
+ }
60
+ /**
61
+ * Build project render data and enhance result from a project parameter.
62
+ * Shared between the full-page preview and the JSON render endpoint.
63
+ */
64
+ async function buildProjectPreviewData(ctx, projectParam, queryOverrides) {
65
+ // Check cache (only when no query overrides, which are rare)
66
+ if (!queryOverrides?.repoUrl && !queryOverrides?.projectUrl) {
67
+ const cached = previewDataCache.get(projectParam);
68
+ if (cached && Date.now() - cached.ts < PREVIEW_CACHE_TTL) {
69
+ return cached.data;
70
+ }
71
+ }
72
+ const rawProjects = await ctx.getProjects();
73
+ const rawProj = rawProjects.find((p) => p.name === projectParam || p.dirName === projectParam);
74
+ if (!rawProj) {
75
+ throw new ProjectNotFoundError(projectParam);
76
+ }
77
+ const proj = await ctx.getProjectWithStats(rawProj);
78
+ const cached = loadProjectEnhanceResult(proj.dirName);
79
+ const auth = getAuthToken();
80
+ const enhanceResult = cached?.result;
81
+ // ── Fast path: read ALL session data from SQLite (single query, no JSONL parsing) ──
82
+ const dbSessions = getSessionsByProject(ctx.db, rawProj.dirName);
83
+ const dbById = new Map(dbSessions.map((r) => [r.id, r]));
84
+ /** Convert a SQLite SessionRow + optional enhanced data into a SessionCard */
85
+ function rowToCard(row, sid) {
86
+ const enhanced = loadEnhancedData(sid);
87
+ const skills = enhanced?.skills ?? (row.skills ? JSON.parse(row.skills) : []);
88
+ const title = enhanced?.title ?? row.title ?? sid;
89
+ const devTake = (enhanced?.developerTake ?? '').slice(0, 2000);
90
+ const slug = toSlug(title, 80);
91
+ // Check for child sessions (subagents) in DB
92
+ const children = dbSessions.filter((r) => r.parent_session_id === sid);
93
+ let agentSummary;
94
+ if (children.length > 0) {
95
+ agentSummary = {
96
+ is_orchestrated: true,
97
+ agents: children.map((c) => ({
98
+ role: c.agent_role ?? 'agent',
99
+ duration_minutes: c.duration_minutes ?? 0,
100
+ loc_changed: (c.loc_added ?? 0) + (c.loc_removed ?? 0),
101
+ })),
102
+ };
103
+ }
104
+ return {
105
+ token: sid,
106
+ slug,
107
+ title,
108
+ devTake,
109
+ durationMinutes: row.duration_minutes ?? 0,
110
+ turns: row.turns ?? 0,
111
+ locChanged: (row.loc_added ?? 0) + (row.loc_removed ?? 0),
112
+ linesAdded: row.loc_added ?? 0,
113
+ linesDeleted: row.loc_removed ?? 0,
114
+ filesChanged: row.files_changed ?? 0,
115
+ skills,
116
+ recordedAt: row.start_time ?? new Date().toISOString(),
117
+ sourceTool: row.source ?? 'claude',
118
+ agentSummary,
119
+ };
120
+ }
121
+ // Build selected session cards from DB
122
+ const sessionCards = [];
123
+ if (cached?.selectedSessionIds) {
124
+ for (const sid of cached.selectedSessionIds) {
125
+ const row = dbById.get(sid);
126
+ if (!row)
127
+ continue;
128
+ sessionCards.push(rowToCard(row, sid));
129
+ }
130
+ }
131
+ // Build ALL session cards from DB (for work timeline + growth chart)
132
+ // Only parent sessions (not subagents) — subagents are included via agentSummary
133
+ const allSessionCards = [];
134
+ const sessionStatsMap = new Map();
135
+ for (const row of dbSessions) {
136
+ if (row.is_subagent)
137
+ continue;
138
+ const enhanced = loadEnhancedData(row.id);
139
+ const skills = enhanced?.skills ?? (row.skills ? JSON.parse(row.skills) : []);
140
+ sessionStatsMap.set(row.id, {
141
+ duration: row.duration_minutes ?? 0,
142
+ date: row.start_time || undefined,
143
+ skills,
144
+ description: enhanced?.context || '',
145
+ });
146
+ allSessionCards.push(rowToCard(row, row.id));
147
+ }
148
+ // Enrich timeline sessions with real stats
149
+ const enrichedTimeline = (enhanceResult?.timeline || []).map((period) => ({
150
+ period: period.period,
151
+ label: period.label,
152
+ sessions: period.sessions.map((s) => {
153
+ const stats = sessionStatsMap.get(s.sessionId);
154
+ return {
155
+ ...s,
156
+ duration: stats?.duration ?? 0,
157
+ date: stats?.date,
158
+ skills: stats?.skills,
159
+ description: stats?.description,
160
+ };
161
+ }),
162
+ }));
163
+ const projAny = proj;
164
+ const rawName = projAny.name || displayNameFromDir(projAny.dirName);
165
+ const title = cached?.title || rawName;
166
+ const slug = toSlug(title);
167
+ // Metadata from enhance cache (set in sidebar), with query overrides taking priority
168
+ const cachedAny = cached;
169
+ const metaRepoUrl = queryOverrides?.repoUrl || cachedAny?.repoUrl;
170
+ const metaProjectUrl = queryOverrides?.projectUrl || cachedAny?.projectUrl;
171
+ const renderData = buildProjectRenderData({
172
+ username: auth?.username || 'preview',
173
+ slug,
174
+ title,
175
+ narrative: enhanceResult?.narrative || projAny.description || '',
176
+ repoUrl: metaRepoUrl,
177
+ projectUrl: metaProjectUrl,
178
+ screenshotUrl: (() => {
179
+ return existsSync(path.join(SCREENSHOTS_DIR, `${slug}.png`))
180
+ ? `/screenshots/${slug}.png`
181
+ : undefined;
182
+ })(),
183
+ timeline: enrichedTimeline,
184
+ skills: enhanceResult?.skills || projAny.skills || [],
185
+ totalSessions: projAny.sessionCount,
186
+ totalLoc: projAny.totalLoc,
187
+ totalDurationMinutes: projAny.totalDuration,
188
+ totalAgentDurationMinutes: projAny.totalAgentDuration,
189
+ totalFilesChanged: projAny.totalFiles,
190
+ totalTokens: (projAny.totalInputTokens || 0) + (projAny.totalOutputTokens || 0) || undefined,
191
+ sessionCards,
192
+ allSessionCards,
193
+ sessionBaseUrl: `/preview/project/${encodeURIComponent(projectParam)}/session`,
194
+ });
195
+ const result = { renderData, enhanceResult, projName: projAny.name };
196
+ // Cache the result (template-agnostic data, re-rendered cheaply per template)
197
+ previewDataCache.set(projectParam, { data: result, ts: Date.now() });
198
+ return result;
199
+ }
200
+ /** Sentinel error for project-not-found so callers can return 404. */
201
+ class ProjectNotFoundError extends Error {
202
+ constructor(project) {
203
+ super(`Project not found: ${project}`);
204
+ this.name = 'ProjectNotFoundError';
205
+ }
206
+ }
13
207
  export function createPreviewRouter(ctx) {
14
208
  const router = Router();
209
+ // Serve template previews with mock data.
210
+ // Tries static mockup HTML first (fast), falls back to Liquid rendering.
211
+ router.get('/preview/template/:name', (req, res) => {
212
+ const name = String(req.params.name);
213
+ if (!isValidTemplate(name)) {
214
+ res.status(404).send('Template not found');
215
+ return;
216
+ }
217
+ // Allowlist page to prevent path traversal via ?page=../../etc/passwd
218
+ // in the path.resolve() call below.
219
+ const VALID_PREVIEW_PAGES = ['portfolio', 'project', 'session'];
220
+ const rawPage = req.query.page === undefined ? 'project' : req.query.page;
221
+ if (typeof rawPage !== 'string' ||
222
+ !VALID_PREVIEW_PAGES.includes(rawPage)) {
223
+ res.status(400).json({
224
+ error: {
225
+ code: 'INVALID_PAGE',
226
+ message: 'page must be one of: portfolio, project, session',
227
+ },
228
+ });
229
+ return;
230
+ }
231
+ const page = rawPage;
232
+ // 1. Try static mockup HTML (instant, from docs/mockups/)
233
+ const mockupPath = path.resolve(__dirname, '..', '..', '..', 'docs', 'mockups', name, `${page}.html`);
234
+ if (existsSync(mockupPath)) {
235
+ let html = readFileSync(mockupPath, 'utf-8');
236
+ html = html.replace(/\.\.\/assets\//g, '/preview/template-assets/');
237
+ html = html.replace(/\.\/portfolio\.html/g, `/preview/template/${name}?page=portfolio`);
238
+ html = html.replace(/\.\/project\.html/g, `/preview/template/${name}?page=project`);
239
+ html = html.replace(/\.\/session\.html/g, `/preview/template/${name}?page=session`);
240
+ res.setHeader('Content-Type', 'text/html');
241
+ res.setHeader('Cache-Control', 'public, max-age=3600');
242
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN');
243
+ res.send(html);
244
+ return;
245
+ }
246
+ // 2. Fall back to Liquid rendering with mock data
247
+ try {
248
+ let bodyHtml;
249
+ if (page === 'portfolio') {
250
+ bodyHtml = renderPortfolioHtml(getMockPortfolioData(), name);
251
+ }
252
+ else if (page === 'session') {
253
+ bodyHtml = renderSessionHtml(getMockSessionData(), name);
254
+ }
255
+ else {
256
+ bodyHtml = renderProjectHtml(getMockProjectData(), { arc: getMockProjectArc(), fullSessions: getMockFullSessions() }, name);
257
+ }
258
+ const css = getTemplateCss(name);
259
+ const fullHtml = `<!DOCTYPE html>
260
+ <html lang="en">
261
+ <head>
262
+ <meta charset="UTF-8">
263
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
264
+ <meta name="heyiam-api-base" content="/api" />
265
+ <title>${name} — ${page} preview</title>
266
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
267
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
268
+ <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" />
269
+ <style>${css}
270
+ /* Preview override */
271
+ body { overflow: auto !important; min-height: auto !important; }
272
+ #root { min-height: auto !important; }
273
+ </style>
274
+ </head>
275
+ <body>
276
+ ${bodyHtml}
277
+ <script src="/heyiam-mount.js"></script>
278
+ </body>
279
+ </html>`;
280
+ res.setHeader('Content-Type', 'text/html');
281
+ res.setHeader('Cache-Control', 'public, max-age=3600');
282
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN');
283
+ res.send(fullHtml);
284
+ }
285
+ catch (err) {
286
+ console.error(`[preview/template] Liquid render failed for ${name}/${page}:`, err.message);
287
+ res.status(500).send('Template render failed');
288
+ }
289
+ });
290
+ // Serve mockup assets (headshots, etc.)
291
+ router.get('/preview/template-assets/:filename', (req, res) => {
292
+ const filename = String(req.params.filename);
293
+ // Only allow expected image files
294
+ if (!/^[\w-]+\.(jpg|png)$/.test(filename)) {
295
+ res.status(400).end();
296
+ return;
297
+ }
298
+ const assetPath = path.resolve(__dirname, '..', '..', '..', 'docs', 'mockups', 'assets', filename);
299
+ if (existsSync(assetPath)) {
300
+ const ext = filename.endsWith('.png') ? 'image/png' : 'image/jpeg';
301
+ res.setHeader('Content-Type', ext);
302
+ res.setHeader('Cache-Control', 'public, max-age=86400');
303
+ res.send(readFileSync(assetPath));
304
+ }
305
+ else {
306
+ res.status(404).end();
307
+ }
308
+ });
15
309
  // Serve local screenshot files
16
310
  router.get('/screenshots/:slug.png', (req, res) => {
17
311
  const filePath = path.join(SCREENSHOTS_DIR, `${req.params.slug}.png`);
@@ -24,120 +318,96 @@ export function createPreviewRouter(ctx) {
24
318
  res.status(404).end();
25
319
  }
26
320
  });
321
+ // Delete a screenshot file
322
+ router.delete('/api/projects/:project/screenshot', (req, res) => {
323
+ const projectParam = String(req.params.project);
324
+ const slug = toSlug(projectParam);
325
+ const filePath = path.join(SCREENSHOTS_DIR, `${slug}.png`);
326
+ try {
327
+ if (existsSync(filePath)) {
328
+ const { unlinkSync } = require('node:fs');
329
+ unlinkSync(filePath);
330
+ }
331
+ // Portfolio listing shows project screenshots — bust the cache.
332
+ invalidatePortfolioPreviewCache();
333
+ res.json({ ok: true });
334
+ }
335
+ catch {
336
+ res.status(500).json({ error: 'Failed to delete screenshot' });
337
+ }
338
+ });
27
339
  // Project preview -- serves full standalone HTML page identical to heyi.am
28
340
  router.get('/preview/project/:project', async (req, res) => {
29
341
  try {
30
342
  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) {
343
+ const templateOverride = req.query.template;
344
+ const { renderData, enhanceResult, projName } = await buildProjectPreviewData(ctx, projectParam, {
345
+ repoUrl: req.query.repoUrl,
346
+ projectUrl: req.query.projectUrl,
347
+ });
348
+ // Use template override if valid, otherwise fall back to user default
349
+ const templateName = (templateOverride && isValidTemplate(templateOverride))
350
+ ? templateOverride
351
+ : (getDefaultTemplate() || 'editorial');
352
+ let bodyHtml;
353
+ try {
354
+ bodyHtml = renderProjectHtml(renderData, { arc: enhanceResult?.arc }, templateName);
355
+ }
356
+ catch {
357
+ bodyHtml = renderProjectHtml(renderData, { arc: enhanceResult?.arc }, 'editorial');
358
+ }
359
+ res.type('html').send(ctx.buildPreviewPage(projName, bodyHtml, 'PREVIEW — this is how your project will appear on heyi.am', templateName));
360
+ }
361
+ catch (err) {
362
+ if (err instanceof ProjectNotFoundError) {
34
363
  res.status(404).send('Project not found');
35
364
  return;
36
365
  }
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
- }
366
+ console.error('[preview] Error:', err.message);
367
+ res.status(500).send('Preview rendering failed');
368
+ }
369
+ });
370
+ // JSON render endpoint -- returns rendered HTML fragment + CSS for embedding in React UI
371
+ router.get('/api/projects/:project/render', async (req, res) => {
372
+ try {
373
+ const projectParam = String(req.params.project);
374
+ const templateOverride = req.query.template;
375
+ const { renderData, enhanceResult } = await buildProjectPreviewData(ctx, projectParam, {
376
+ repoUrl: req.query.repoUrl,
377
+ projectUrl: req.query.projectUrl,
378
+ });
379
+ // Override sessionBaseUrl so Liquid generates SPA-friendly /session/:id links
380
+ // (the cached renderData uses /preview/project/... URLs for the standalone preview)
381
+ const spaRenderData = { ...renderData, sessionBaseUrl: '/session' };
382
+ // Use template override if valid, otherwise fall back to user default
383
+ let templateName = (templateOverride && isValidTemplate(templateOverride))
384
+ ? templateOverride
385
+ : (getDefaultTemplate() || 'editorial');
386
+ let html;
387
+ try {
388
+ html = renderProjectHtml(spaRenderData, { arc: enhanceResult?.arc }, templateName);
64
389
  }
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`,
390
+ catch {
391
+ // Template files may not exist yet (e.g. showcase) -- fall back to editorial
392
+ templateName = 'editorial';
393
+ html = renderProjectHtml(spaRenderData, { arc: enhanceResult?.arc }, templateName);
394
+ }
395
+ const css = getTemplateCss(templateName);
396
+ const screenshotUrl = spaRenderData.project.screenshotUrl || undefined;
397
+ const templateInfo = getTemplateInfo(templateName);
398
+ res.json({
399
+ html, css, template: templateName, screenshotUrl,
400
+ accent: templateInfo?.accent ?? '#084471',
401
+ mode: templateInfo?.mode ?? 'light',
134
402
  });
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
403
  }
138
404
  catch (err) {
139
- console.error('[preview] Error:', err.message);
140
- res.status(500).send('Preview rendering failed');
405
+ if (err instanceof ProjectNotFoundError) {
406
+ res.status(404).json({ error: 'Project not found' });
407
+ return;
408
+ }
409
+ console.error('[api/render] Error:', err.message);
410
+ res.status(500).json({ error: 'Render failed' });
141
411
  }
142
412
  });
143
413
  // Session preview
@@ -160,6 +430,9 @@ export function createPreviewRouter(ctx) {
160
430
  const session = await ctx.loadSession(meta.path, rawProj.name, sessionId);
161
431
  const enhanced = loadEnhancedData(sessionId);
162
432
  const agentSummary = await buildAgentSummary(meta.children ?? [], (c) => ctx.getSessionStats(c, rawProj.name));
433
+ const templateName = req.query.template && isValidTemplate(req.query.template)
434
+ ? req.query.template
435
+ : (getDefaultTemplate() || 'editorial');
163
436
  const renderData = buildSessionRenderData({
164
437
  sessionId,
165
438
  session,
@@ -169,15 +442,189 @@ export function createPreviewRouter(ctx) {
169
442
  sessionSlug: sessionId,
170
443
  sourceTool: session.source || 'claude',
171
444
  agentSummary,
445
+ template: templateName,
172
446
  });
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'));
447
+ const bodyHtml = renderSessionHtml(renderData, templateName);
448
+ res.type('html').send(ctx.buildPreviewPage(session.title || sessionId, bodyHtml, 'PREVIEW — this is how your session will appear on heyi.am', templateName));
175
449
  }
176
450
  catch (err) {
177
451
  console.error('[session-preview] Error:', err.message);
178
452
  res.status(500).send('Session preview failed');
179
453
  }
180
454
  });
455
+ // JSON render endpoint for sessions — returns HTML fragment + CSS for embedding in React UI
456
+ router.get('/api/sessions/:sessionId/render', async (req, res) => {
457
+ try {
458
+ const sessionId = String(req.params.sessionId);
459
+ const templateOverride = req.query.template;
460
+ // Find the session across all projects
461
+ const rawProjects = await ctx.getProjects();
462
+ let foundMeta;
463
+ let foundProj;
464
+ for (const proj of rawProjects) {
465
+ const meta = proj.sessions.find((s) => s.sessionId === sessionId);
466
+ if (meta) {
467
+ foundMeta = meta;
468
+ foundProj = proj;
469
+ break;
470
+ }
471
+ }
472
+ if (!foundMeta || !foundProj) {
473
+ res.status(404).json({ error: 'Session not found' });
474
+ return;
475
+ }
476
+ const auth = getAuthToken();
477
+ const session = await ctx.loadSession(foundMeta.path, foundProj.name, sessionId);
478
+ const enhanced = loadEnhancedData(sessionId);
479
+ const agentSummary = await buildAgentSummary(foundMeta.children ?? [], (c) => ctx.getSessionStats(c, foundProj.name));
480
+ let templateName = (templateOverride && isValidTemplate(templateOverride))
481
+ ? templateOverride
482
+ : (getDefaultTemplate() || 'editorial');
483
+ const renderData = buildSessionRenderData({
484
+ sessionId,
485
+ session,
486
+ enhanced,
487
+ username: auth?.username || 'preview',
488
+ projectSlug: foundProj.dirName,
489
+ sessionSlug: sessionId,
490
+ sourceTool: session.source || 'claude',
491
+ agentSummary,
492
+ template: templateName,
493
+ });
494
+ let html;
495
+ try {
496
+ html = renderSessionHtml(renderData, templateName);
497
+ }
498
+ catch {
499
+ templateName = 'editorial';
500
+ html = renderSessionHtml(renderData, templateName);
501
+ }
502
+ const css = getTemplateCss(templateName);
503
+ const templateInfo = getTemplateInfo(templateName);
504
+ res.json({
505
+ html, css, template: templateName,
506
+ accent: templateInfo?.accent ?? '#084471',
507
+ mode: templateInfo?.mode ?? 'light',
508
+ });
509
+ }
510
+ catch (err) {
511
+ console.error('[api/session/render] Error:', err.message);
512
+ res.status(500).json({ error: 'Session render failed' });
513
+ }
514
+ });
515
+ // Portfolio preview -- serves full standalone HTML page with real user data.
516
+ // Cached for PORTFOLIO_PREVIEW_CACHE_TTL ms; the React PreviewPane reloads
517
+ // the iframe on every keystroke (post-debounce), and re-rendering every
518
+ // project's Liquid template on each hit is expensive.
519
+ router.get('/preview/portfolio', async (req, res) => {
520
+ // Optional template override (?template=:name) lets the template browser
521
+ // preview real user data through any template without changing the saved
522
+ // default. Falls back to the user's default when missing or invalid.
523
+ const templateOverride = typeof req.query.template === 'string' ? req.query.template : undefined;
524
+ const templateName = (templateOverride && isValidTemplate(templateOverride))
525
+ ? templateOverride
526
+ : (getDefaultTemplate() || 'editorial');
527
+ const cacheKey = portfolioCacheKey(templateName);
528
+ const cached = portfolioPreviewCache.get(cacheKey);
529
+ if (cached && cached.expiresAt > Date.now()) {
530
+ res.type('html').send(cached.html);
531
+ return;
532
+ }
533
+ try {
534
+ const profile = getPortfolioProfile();
535
+ const auth = getAuthToken();
536
+ // Build portfolio projects from real project data, filtered by user curation
537
+ const allRawProjects = await ctx.getProjects();
538
+ const recencyByDir = new Map();
539
+ try {
540
+ for (const s of getAllProjectStats(ctx.db)) {
541
+ recencyByDir.set(s.projectDir, s.latestDate || '');
542
+ }
543
+ }
544
+ catch { /* DB may be empty on first run */ }
545
+ const rawProjects = applyPortfolioProjectFilter(allRawProjects, profile.projectsOnPortfolio, { getRecency: (p) => recencyByDir.get(p.dirName) });
546
+ const portfolioProjects = [];
547
+ let totalDuration = 0;
548
+ let totalAgentDuration = 0;
549
+ let totalLoc = 0;
550
+ let totalSessions = 0;
551
+ for (const rawProj of rawProjects) {
552
+ try {
553
+ const proj = await ctx.getProjectWithStats(rawProj);
554
+ const cached = loadProjectEnhanceResult(rawProj.dirName);
555
+ const projDuration = proj.totalDuration || 0;
556
+ const projAgentDuration = proj.totalAgentDuration || 0;
557
+ const projLoc = proj.totalLoc || 0;
558
+ const projSessions = proj.sessionCount || 0;
559
+ totalDuration += projDuration;
560
+ totalAgentDuration += projAgentDuration;
561
+ totalLoc += projLoc;
562
+ totalSessions += projSessions;
563
+ const title = cached?.title
564
+ || proj.name || displayNameFromDir(rawProj.dirName);
565
+ // Session activity for charts
566
+ const dbSessions = getSessionsByProject(ctx.db, rawProj.dirName);
567
+ const sessionActivity = dbSessions
568
+ .filter(s => !s.is_subagent)
569
+ .map(s => ({
570
+ date: s.start_time || '',
571
+ loc: (s.loc_added || 0) + (s.loc_removed || 0),
572
+ durationMinutes: s.duration_minutes || 0,
573
+ }));
574
+ portfolioProjects.push({
575
+ slug: toSlug(title),
576
+ title,
577
+ narrative: cached?.result?.narrative || proj.description || '',
578
+ totalSessions: projSessions,
579
+ totalLoc: projLoc,
580
+ totalDurationMinutes: projDuration,
581
+ totalAgentDurationMinutes: projAgentDuration,
582
+ totalFilesChanged: proj.totalFiles || 0,
583
+ skills: cached?.result?.skills || proj.skills || [],
584
+ publishedCount: 0,
585
+ sessions: sessionActivity,
586
+ });
587
+ }
588
+ catch { /* skip projects that fail */ }
589
+ }
590
+ // Always use real project data; fall back gracefully for missing profile fields
591
+ const username = auth?.username || 'preview';
592
+ const renderData = {
593
+ user: {
594
+ username,
595
+ accent: '#084471',
596
+ displayName: profile.displayName || '',
597
+ bio: profile.bio || '',
598
+ location: profile.location || '',
599
+ status: 'active',
600
+ email: profile.email,
601
+ phone: profile.phone,
602
+ photoUrl: profile.photoBase64 || undefined,
603
+ linkedinUrl: profile.linkedinUrl,
604
+ githubUrl: profile.githubUrl,
605
+ twitterHandle: profile.twitterHandle,
606
+ websiteUrl: profile.websiteUrl,
607
+ resumeUrl: profile.resumeBase64 ? '#' : undefined,
608
+ },
609
+ projects: portfolioProjects,
610
+ totalDurationMinutes: totalDuration,
611
+ totalAgentDurationMinutes: totalAgentDuration || undefined,
612
+ totalLoc,
613
+ totalSessions,
614
+ };
615
+ const bodyHtml = renderPortfolioHtml(renderData, templateName);
616
+ const fullHtml = ctx.buildPreviewPage(renderData.user.displayName ? `${renderData.user.displayName}'s Portfolio` : 'Portfolio Preview', bodyHtml, undefined, templateName);
617
+ portfolioPreviewCache.set(cacheKey, {
618
+ html: fullHtml,
619
+ expiresAt: Date.now() + PORTFOLIO_PREVIEW_CACHE_TTL,
620
+ });
621
+ res.type('html').send(fullHtml);
622
+ }
623
+ catch (err) {
624
+ console.error('[portfolio-preview] Error:', err.message);
625
+ res.status(500).send('Portfolio preview failed');
626
+ }
627
+ });
181
628
  // Serve @heyiam/ui mount script for preview pages
182
629
  router.get('/heyiam-mount.js', (_req, res) => {
183
630
  // In built dist: dist/mount.js (copied during build)