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
package/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # heyiam
2
+
3
+ [![npm version](https://img.shields.io/npm/v/heyiam)](https://www.npmjs.com/package/heyiam)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/interactivecats/heyi.am/blob/main/LICENSE)
5
+
6
+ Local-first CLI that indexes your AI coding sessions. Search locally, enhance with AI, and publish a portfolio of real work.
7
+
8
+ Discovers sessions from **Claude Code**, **Cursor**, **OpenAI Codex CLI**, and **Google Gemini CLI**.
9
+
10
+ ## Get Started
11
+
12
+ ```bash
13
+ npx heyiam
14
+ ```
15
+
16
+ Opens a local dashboard at `localhost:17845`. Browse projects, search sessions, and publish portfolio case studies to `heyi.am/:username`.
17
+
18
+ ## Commands
19
+
20
+ | Command | Description |
21
+ |---------|-------------|
22
+ | `heyiam` / `heyiam open` | Start local dashboard |
23
+ | `heyiam search [query]` | Full-text search across all sessions |
24
+ | `heyiam time` | Your time vs agent time per project |
25
+ | `heyiam context <id>` | Export session as compressed context for AI tools |
26
+ | `heyiam archive` | Discover and archive sessions from all sources |
27
+ | `heyiam sync` | Index sessions into SQLite |
28
+ | `heyiam status` | Archive health, session counts, daemon status |
29
+
30
+ ## Privacy
31
+
32
+ Everything stays local by default. Nothing leaves your machine unless you explicitly publish.
33
+
34
+ - Sessions are read from local tool storage (read-only)
35
+ - SQLite search index lives at `~/.local/share/heyiam/`
36
+ - Config at `~/.config/heyiam/`
37
+ - Common secret patterns are detected and redacted before upload, but this is not a guarantee
38
+
39
+ AI enhancement runs locally using your own `ANTHROPIC_API_KEY`.
40
+
41
+ ## Links
42
+
43
+ - [heyi.am](https://heyi.am) — published portfolios
44
+ - [heyiam.com](https://heyiam.com) — dashboard and auth
45
+ - [GitHub](https://github.com/interactivecats/heyi.am) — source code
package/dist/auth.js CHANGED
@@ -19,7 +19,16 @@ export function writeConfig(filename, data, configDir = getConfigDir()) {
19
19
  writeFileSync(join(configDir, filename), JSON.stringify(data, null, 2), { mode: 0o600 });
20
20
  }
21
21
  export function getAuthToken(configDir = getConfigDir()) {
22
- return readConfig(AUTH_FILE, configDir);
22
+ const raw = readConfig(AUTH_FILE, configDir);
23
+ if (!raw)
24
+ return null;
25
+ // Legacy configs may contain mixed-case usernames (prior to the
26
+ // normalize-on-write change). Always project to the canonical form so
27
+ // callers — URL construction, display, server requests — stay consistent.
28
+ if (raw.username && raw.username !== raw.username.toLowerCase()) {
29
+ return { ...raw, username: normalizeUsername(raw.username) };
30
+ }
31
+ return raw;
23
32
  }
24
33
  export function deleteAuthToken(configDir = getConfigDir()) {
25
34
  const filePath = join(configDir, AUTH_FILE);
@@ -27,8 +36,22 @@ export function deleteAuthToken(configDir = getConfigDir()) {
27
36
  unlinkSync(filePath);
28
37
  }
29
38
  }
39
+ /**
40
+ * Normalize a username to the canonical form used everywhere in the CLI:
41
+ * lowercase, whitespace trimmed. Mirrors Phoenix's DB validation regex
42
+ * `^[a-z0-9-]+$`. Guards against Phoenix responses or legacy configs that
43
+ * contain mixed-case usernames (e.g. "Ben" -> "ben") so URL construction
44
+ * and display are consistent.
45
+ */
46
+ export function normalizeUsername(username) {
47
+ return username.trim().toLowerCase();
48
+ }
30
49
  export function saveAuthToken(token, username, configDir = getConfigDir()) {
31
- const config = { token, username, savedAt: new Date().toISOString() };
50
+ const config = {
51
+ token,
52
+ username: normalizeUsername(username),
53
+ savedAt: new Date().toISOString(),
54
+ };
32
55
  writeConfig(AUTH_FILE, config, configDir);
33
56
  }
34
57
  export async function checkAuthStatus(apiBaseUrl, configDir = getConfigDir(), fetchFn = fetch) {
@@ -41,7 +64,10 @@ export async function checkAuthStatus(apiBaseUrl, configDir = getConfigDir(), fe
41
64
  if (!res.ok)
42
65
  return { authenticated: false };
43
66
  const body = (await res.json());
44
- return { authenticated: true, username: body.username };
67
+ return {
68
+ authenticated: true,
69
+ username: body.username ? normalizeUsername(body.username) : body.username,
70
+ };
45
71
  }
46
72
  export async function deviceAuthFlow(apiBaseUrl, configDir = getConfigDir(), options = {}) {
47
73
  const fetchFn = options.fetchFn ?? fetch;
package/dist/config.js CHANGED
@@ -1,4 +1,13 @@
1
1
  /** Base URL for the heyiam.com app API. Override with HEYIAM_API_URL for local dev. */
2
- export const API_URL = process.env.HEYIAM_API_URL ?? 'https://heyiam.com';
2
+ const DEFAULT_API_URL = 'https://heyiam.com';
3
+ export const API_URL = process.env.HEYIAM_API_URL ?? DEFAULT_API_URL;
3
4
  /** Base URL for the heyi.am public site. Override with HEYIAM_PUBLIC_URL for local dev. */
4
5
  export const PUBLIC_URL = process.env.HEYIAM_PUBLIC_URL ?? 'https://heyi.am';
6
+ /** Warn once to stderr if a non-default API URL is in use (env var override). */
7
+ let _apiUrlWarned = false;
8
+ export function warnIfNonDefaultApiUrl() {
9
+ if (!_apiUrlWarned && API_URL !== DEFAULT_API_URL) {
10
+ console.warn(`[security] API_URL overridden to ${API_URL} — auth tokens will be sent to this host`);
11
+ _apiUrlWarned = true;
12
+ }
13
+ }
package/dist/db.js CHANGED
@@ -12,7 +12,6 @@ function getDataDir() {
12
12
  export function getDbPath() {
13
13
  return join(getDataDir(), 'sessions.db');
14
14
  }
15
- export const DB_PATH = join(homedir(), '.local', 'share', 'heyiam', 'sessions.db');
16
15
  const CURRENT_SCHEMA_VERSION = 5;
17
16
  // ── Singleton ────────────────────────────────────────────────
18
17
  let _db = null;
package/dist/export.js CHANGED
@@ -9,9 +9,10 @@ import { mkdirSync, writeFileSync, readFileSync, statSync, existsSync } from 'no
9
9
  import { deflateRawSync } from 'node:zlib';
10
10
  import { join, resolve, dirname } from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
- import { loadEnhancedData } from './settings.js';
13
- import { renderProjectHtml, renderSessionHtml } from './render/index.js';
14
- import { escapeHtml, displayNameFromDir } from './format-utils.js';
12
+ import { loadEnhancedData, getDefaultTemplate } from './settings.js';
13
+ import { renderProjectHtml, renderSessionHtml, renderPortfolioHtml } from './render/index.js';
14
+ import { resolveTemplate, getTemplateCss } from './render/templates.js';
15
+ import { escapeHtml, displayNameFromDir, toSlug } from './format-utils.js';
15
16
  import { buildProjectRenderData, buildSessionRenderData, buildSessionCard, } from './render/build-render-data.js';
16
17
  import { mergeActiveIntervals, sumIntervalMs } from './bridge.js';
17
18
  import { SCREENSHOTS_DIR } from './screenshot.js';
@@ -42,7 +43,7 @@ function resolveScreenshotDataUri(dirName, cache) {
42
43
  return `data:image/png;base64,${b64}`;
43
44
  }
44
45
  // Try local screenshot file
45
- const slug = dirName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
46
+ const slug = toSlug(dirName);
46
47
  const screenshotPath = join(SCREENSHOTS_DIR, `${slug}.png`);
47
48
  if (existsSync(screenshotPath)) {
48
49
  const buf = readFileSync(screenshotPath);
@@ -220,16 +221,19 @@ export async function exportHtml(dirName, cache, sessions, outputPath, username
220
221
  sourceTool: session.source ?? 'unknown',
221
222
  });
222
223
  });
223
- // Compute stats the same way the dashboard does
224
+ // Prefer DB-computed stats from opts (includes subagent data); fall back to session-based computation
224
225
  const totalLoc = sessions.reduce((sum, s) => sum + s.linesOfCode, 0);
225
226
  const totalDurationMinutes = computeMergedSessionDuration(sessions);
226
227
  const totalFilesChanged = opts?.totalFilesChanged ?? new Set(sessions.flatMap(s => s.filesChanged.map(f => f.path))).size;
227
- // Agent duration: sum child durations across all orchestrated sessions
228
- const totalAgentMinutes = sessions
229
- .filter((s) => s.isOrchestrated && s.children)
230
- .reduce((sum, s) => sum + s.children.reduce((cs, c) => cs + c.durationMinutes, 0), 0);
231
- const totalAgentDurationMinutes = totalAgentMinutes > 0 ? totalDurationMinutes + totalAgentMinutes : undefined;
232
- const totalTokens = sessions.reduce((sum, s) => sum + (s.tokenUsage?.input ?? 0) + (s.tokenUsage?.output ?? 0), 0) || undefined;
228
+ const totalAgentDurationMinutes = opts?.totalAgentDurationMinutes ?? (() => {
229
+ const agentMin = sessions
230
+ .filter((s) => s.isOrchestrated && s.children)
231
+ .reduce((sum, s) => sum + s.children.reduce((cs, c) => cs + c.durationMinutes, 0), 0);
232
+ return agentMin > 0 ? totalDurationMinutes + agentMin : undefined;
233
+ })();
234
+ const totalTokens = (opts?.totalInputTokens || opts?.totalOutputTokens)
235
+ ? (opts.totalInputTokens ?? 0) + (opts.totalOutputTokens ?? 0) || undefined
236
+ : sessions.reduce((sum, s) => sum + (s.tokenUsage?.input ?? 0) + (s.tokenUsage?.output ?? 0), 0) || undefined;
233
237
  // Resolve screenshot for embedding
234
238
  const screenshotUrl = resolveScreenshotDataUri(dirName, cache);
235
239
  // Render project index.html — pass same data shape as dashboard
@@ -256,12 +260,14 @@ export async function exportHtml(dirName, cache, sessions, outputPath, username
256
260
  sessionCards,
257
261
  sessionBaseUrl: './sessions',
258
262
  });
263
+ const templateName = resolveTemplate(undefined, getDefaultTemplate());
259
264
  const projectBody = renderProjectHtml(projectRenderData, {
260
265
  arc: result.arc,
261
266
  fullSessions: sessions,
262
- });
267
+ }, templateName);
263
268
  const projectHtml = buildStandalonePage(title, projectBody, {
264
269
  description: result.narrative?.slice(0, 200) || undefined,
270
+ templateName,
265
271
  });
266
272
  totalBytes += writeAndTrack(join(outputPath, 'index.html'), projectHtml, files);
267
273
  // Render session pages — only featured sessions (linked from project page)
@@ -279,10 +285,11 @@ export async function exportHtml(dirName, cache, sessions, outputPath, username
279
285
  projectSlug: slug,
280
286
  sessionSlug,
281
287
  sourceTool: session.source ?? 'unknown',
288
+ template: templateName,
282
289
  });
283
- const sessionBody = renderSessionHtml(renderData);
290
+ const sessionBody = renderSessionHtml(renderData, templateName);
284
291
  const sessionDesc = (enhanced?.developerTake ?? session.developerTake ?? '').slice(0, 200) || undefined;
285
- const sessionHtml = buildStandalonePage(session.title, sessionBody, { description: sessionDesc });
292
+ const sessionHtml = buildStandalonePage(session.title, sessionBody, { description: sessionDesc, templateName });
286
293
  totalBytes += writeAndTrack(join(sessionsDir, `${sessionSlug}.html`), sessionHtml, files);
287
294
  }
288
295
  return { files, totalBytes, outputPath };
@@ -318,11 +325,15 @@ function buildProjectRenderInputs(dirName, cache, sessions, username, opts) {
318
325
  const totalLoc = sessions.reduce((sum, s) => sum + s.linesOfCode, 0);
319
326
  const totalDurationMinutes = computeMergedSessionDuration(sessions);
320
327
  const totalFilesChanged = opts?.totalFilesChanged ?? new Set(sessions.flatMap(s => s.filesChanged.map(f => f.path))).size;
321
- const totalAgentMinutes = sessions
322
- .filter((s) => s.isOrchestrated && s.children)
323
- .reduce((sum, s) => sum + s.children.reduce((cs, c) => cs + c.durationMinutes, 0), 0);
324
- const totalAgentDurationMinutes = totalAgentMinutes > 0 ? totalDurationMinutes + totalAgentMinutes : undefined;
325
- const totalTokens = sessions.reduce((sum, s) => sum + (s.tokenUsage?.input ?? 0) + (s.tokenUsage?.output ?? 0), 0) || undefined;
328
+ const totalAgentDurationMinutes = opts?.totalAgentDurationMinutes ?? (() => {
329
+ const agentMin = sessions
330
+ .filter((s) => s.isOrchestrated && s.children)
331
+ .reduce((sum, s) => sum + s.children.reduce((cs, c) => cs + c.durationMinutes, 0), 0);
332
+ return agentMin > 0 ? totalDurationMinutes + agentMin : undefined;
333
+ })();
334
+ const totalTokens = (opts?.totalInputTokens || opts?.totalOutputTokens)
335
+ ? (opts.totalInputTokens ?? 0) + (opts.totalOutputTokens ?? 0) || undefined
336
+ : sessions.reduce((sum, s) => sum + (s.tokenUsage?.input ?? 0) + (s.tokenUsage?.output ?? 0), 0) || undefined;
326
337
  return { result, slug, title, sessionCards, totalLoc, totalDurationMinutes, totalAgentDurationMinutes, totalFilesChanged, totalTokens };
327
338
  }
328
339
  /**
@@ -346,15 +357,17 @@ export function generateProjectHtmlFragment(dirName, cache, sessions, username =
346
357
  totalLoc, totalDurationMinutes, totalAgentDurationMinutes, totalFilesChanged, totalTokens,
347
358
  sessionCards,
348
359
  });
360
+ const templateName = resolveTemplate(undefined, getDefaultTemplate());
349
361
  return renderProjectHtml(renderData, {
350
362
  arc: result.arc,
351
363
  fullSessions: sessions,
352
- });
364
+ }, templateName);
353
365
  }
354
366
  export function generateHtmlFiles(dirName, cache, sessions, username = 'local', opts) {
355
367
  const files = [];
356
368
  const { result, slug, title, sessionCards, totalLoc, totalDurationMinutes, totalAgentDurationMinutes, totalFilesChanged, totalTokens } = buildProjectRenderInputs(dirName, cache, sessions, username, opts);
357
369
  const screenshotUrl = resolveScreenshotDataUri(dirName, cache);
370
+ const templateName = resolveTemplate(undefined, getDefaultTemplate());
358
371
  const projectRenderData = buildProjectRenderData({
359
372
  username, slug, title,
360
373
  narrative: result.narrative,
@@ -371,9 +384,10 @@ export function generateHtmlFiles(dirName, cache, sessions, username = 'local',
371
384
  const projectBody = renderProjectHtml(projectRenderData, {
372
385
  arc: result.arc,
373
386
  fullSessions: sessions,
374
- });
387
+ }, templateName);
375
388
  files.push({ path: 'index.html', content: buildStandalonePage(title, projectBody, {
376
389
  description: result.narrative?.slice(0, 200) || undefined,
390
+ templateName,
377
391
  }) });
378
392
  const featuredSessions = pickFeaturedSessions(sessions, cache);
379
393
  for (const session of featuredSessions) {
@@ -384,12 +398,13 @@ export function generateHtmlFiles(dirName, cache, sessions, username = 'local',
384
398
  session, enhanced, username,
385
399
  projectSlug: slug, sessionSlug,
386
400
  sourceTool: session.source ?? 'unknown',
401
+ template: templateName,
387
402
  });
388
- const sessionBody = renderSessionHtml(renderData);
403
+ const sessionBody = renderSessionHtml(renderData, templateName);
389
404
  const sessionDesc = (enhanced?.developerTake ?? session.developerTake ?? '').slice(0, 200) || undefined;
390
405
  files.push({
391
406
  path: `sessions/${sessionSlug}.html`,
392
- content: buildStandalonePage(session.title, sessionBody, { description: sessionDesc }),
407
+ content: buildStandalonePage(session.title, sessionBody, { description: sessionDesc, templateName }),
393
408
  });
394
409
  }
395
410
  return files;
@@ -409,7 +424,9 @@ export function createZipBuffer(entries) {
409
424
  const dosDate = (((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate()) & 0xffff;
410
425
  for (const entry of entries) {
411
426
  const nameBytes = Buffer.from(entry.path, 'utf-8');
412
- const raw = Buffer.from(entry.content, 'utf-8');
427
+ const raw = Buffer.isBuffer(entry.content)
428
+ ? entry.content
429
+ : Buffer.from(entry.content, 'utf-8');
413
430
  const compressed = deflateRawSync(raw);
414
431
  const crc = crc32(raw);
415
432
  // Local file header
@@ -490,9 +507,9 @@ function getInlineMountJs() {
490
507
  }
491
508
  }
492
509
  function buildStandalonePage(title, bodyHtml, opts) {
493
- const css = getInlineCss();
510
+ const css = opts?.templateName ? getTemplateCss(opts.templateName) : getInlineCss();
494
511
  const cssTag = css
495
- ? `<style>${css}\nbody { overflow: auto !important; min-height: auto !important; background: var(--color-surface, #f8f9fb); }</style>`
512
+ ? `<style>${css}\nbody { overflow: auto !important; min-height: auto !important; }\n#root { min-height: auto !important; }</style>`
496
513
  : '';
497
514
  const mountJs = getInlineMountJs();
498
515
  const scriptTag = mountJs ? `<script>${mountJs}</script>` : '';
@@ -516,7 +533,7 @@ function buildStandalonePage(title, bodyHtml, opts) {
516
533
  ${ogTags}
517
534
  <link rel="preconnect" href="https://fonts.googleapis.com" />
518
535
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
519
- <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&display=swap" rel="stylesheet" />
536
+ <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" />
520
537
  ${cssTag}
521
538
  </head>
522
539
  <body>
@@ -525,3 +542,83 @@ function buildStandalonePage(title, bodyHtml, opts) {
525
542
  </body>
526
543
  </html>`;
527
544
  }
545
+ // ── Portfolio site export (Phase 1) ────────────────────────────
546
+ /**
547
+ * Render a portfolio landing page to a body HTML fragment (no `<html>` shell).
548
+ *
549
+ * Used by the upload path (Phase 2) to store pre-rendered HTML in
550
+ * `users.rendered_portfolio_html` for Phoenix to serve.
551
+ */
552
+ export function generatePortfolioHtmlFragment(data, templateName) {
553
+ const template = templateName ?? resolveTemplate(undefined, getDefaultTemplate());
554
+ return renderPortfolioHtml(data, template);
555
+ }
556
+ /**
557
+ * Rewrite hardcoded `/{username}/{slug}` absolute project links produced by
558
+ * the current portfolio Liquid templates into relative links that resolve
559
+ * against `projects/{slug}/index.html` when opened from disk.
560
+ *
561
+ * AUDIT FINDING (Phase 1): all 29 portfolio.liquid templates (editorial,
562
+ * blueprint, kinetic, and 26 others) hardcode `/{{ user.username }}/{{ p.slug }}`.
563
+ * Introducing a per-context base URL variable would require changes to
564
+ * `render/liquid.ts` and every template — out of scope for this phase, which
565
+ * only permits editing export.ts, types.ts, and three template files. We
566
+ * rewrite the URLs at the generator boundary instead. Hosted Phoenix serving
567
+ * is unaffected: the render output still contains absolute paths when routed
568
+ * through `renderPortfolioHtml` directly.
569
+ */
570
+ function rewritePortfolioProjectLinks(html, username) {
571
+ // Escape regex metacharacters in username before embedding.
572
+ const safeUser = username.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
573
+ // Matches href="/{username}/{slug}" and href="/{username}/{slug}/{session}"
574
+ const projectOnly = new RegExp(`href="/${safeUser}/([a-z0-9][a-z0-9-]*)"`, 'g');
575
+ const projectSession = new RegExp(`href="/${safeUser}/([a-z0-9][a-z0-9-]*)/([a-z0-9][a-z0-9-]*)"`, 'g');
576
+ return html
577
+ .replace(projectSession, 'href="projects/$1/sessions/$2.html"')
578
+ .replace(projectOnly, 'href="projects/$1/index.html"');
579
+ }
580
+ /**
581
+ * Generate a complete static portfolio site as a directory tree.
582
+ *
583
+ * Output structure:
584
+ * ```
585
+ * {outputDir}/
586
+ * index.html — portfolio landing page
587
+ * projects/{slug}/index.html — per-project detail page
588
+ * projects/{slug}/sessions/{slug}.html — per-session pages (featured)
589
+ * ```
590
+ *
591
+ * All internal links are relative; the resulting directory can be opened
592
+ * directly via `file://` without a server. Font loading remains CDN-linked.
593
+ *
594
+ * @param portfolioData Render data for the landing page.
595
+ * @param projects Per-project inputs. Each must have a unique dirName.
596
+ * @param outputDir Absolute output directory. Caller is responsible for
597
+ * validating the path before calling.
598
+ * @param templateName Optional template override (defaults to user setting).
599
+ */
600
+ export async function generatePortfolioSite(portfolioData, projects, outputDir, templateName) {
601
+ const files = [];
602
+ let totalBytes = 0;
603
+ mkdirSync(outputDir, { recursive: true });
604
+ const template = templateName ?? resolveTemplate(undefined, getDefaultTemplate());
605
+ const username = portfolioData.user.username;
606
+ // ── Landing page ────────────────────────────────────────────
607
+ const portfolioBody = rewritePortfolioProjectLinks(renderPortfolioHtml(portfolioData, template), username);
608
+ const portfolioHtml = buildStandalonePage(portfolioData.user.displayName || username, portfolioBody, {
609
+ description: portfolioData.user.bio?.slice(0, 200) || undefined,
610
+ templateName: template,
611
+ });
612
+ totalBytes += writeAndTrack(join(outputDir, 'index.html'), portfolioHtml, files);
613
+ // ── Per-project sub-sites ───────────────────────────────────
614
+ const projectsRoot = join(outputDir, 'projects');
615
+ mkdirSync(projectsRoot, { recursive: true });
616
+ for (const p of projects) {
617
+ const projectSlug = slugify(p.dirName);
618
+ const projectDir = join(projectsRoot, projectSlug);
619
+ const result = await exportHtml(p.dirName, p.cache, p.sessions, projectDir, username, p.opts);
620
+ files.push(...result.files);
621
+ totalBytes += result.totalBytes;
622
+ }
623
+ return { files, totalBytes, outputPath: outputDir };
624
+ }
@@ -16,6 +16,11 @@ export function displayNameFromDir(dirName) {
16
16
  const segments = dirName.split('-').filter(Boolean);
17
17
  return segments.length > 0 ? segments[segments.length - 1] : dirName;
18
18
  }
19
+ /** Generate a URL-safe slug from a string. */
20
+ export function toSlug(s, maxLen) {
21
+ const slug = s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
22
+ return maxLen !== undefined ? slug.slice(0, maxLen) : slug;
23
+ }
19
24
  /** Escape LIKE wildcards in user input for safe use in SQL LIKE clauses. */
20
25
  export function escapeLikeWildcards(str) {
21
26
  return str.replace(/[%_]/g, (c) => `\\${c}`);