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
@@ -5,8 +5,8 @@
5
5
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>app</title>
8
- <script type="module" crossorigin src="/assets/index-CC9G8EF1.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-Dalqz2mC.css">
8
+ <script type="module" crossorigin src="/assets/index-D0noVMFu.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-Coilyhtr.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/dist/redact.js CHANGED
@@ -1,74 +1,16 @@
1
1
  // Redact — scans text for secrets, PII, and sensitive file paths before publish.
2
2
  //
3
- // Two layers:
4
- // 1. secretlint (community-maintained rules for Anthropic, GitHub, Slack, npm, etc.)
5
- // 2. Custom regex (fills gaps: OpenAI, Stripe, Google, JWT, Bearer, PII, paths)
3
+ // Custom regex patterns for common secret formats (API keys, tokens, PII, paths).
6
4
  //
7
5
  // Two severity levels:
8
6
  // HIGH — auto-redacted (known API key prefixes, private keys, connection strings)
9
7
  // MEDIUM — flagged for user review (email addresses)
10
8
  //
11
9
  // Usage:
12
- // const findings = await scanText(text);
13
- // const cleaned = await redactText(text);
14
- // const results = await scanSession(session);
10
+ // const findings = scanTextSync(text);
11
+ // const cleaned = redactText(text);
15
12
  import { homedir } from "node:os";
16
- // ── Secretlint integration ─────────────────────────────────────
17
- let _engine = null;
18
- let _engineInitFailed = false;
19
- async function getSecretlintEngine() {
20
- if (_engine)
21
- return _engine;
22
- if (_engineInitFailed)
23
- return null;
24
- try {
25
- const { createEngine } = await import("@secretlint/node");
26
- _engine = await createEngine({
27
- color: false,
28
- formatter: "json",
29
- maskSecrets: false,
30
- configFileJSON: {
31
- rules: [{
32
- id: "@secretlint/secretlint-rule-preset-recommend",
33
- }],
34
- },
35
- });
36
- return _engine;
37
- }
38
- catch {
39
- _engineInitFailed = true;
40
- return null;
41
- }
42
- }
43
- async function secretlintScan(text) {
44
- const engine = await getSecretlintEngine();
45
- if (!engine)
46
- return [];
47
- try {
48
- const { ok, output } = await engine.executeOnContent({ content: text, filePath: "/scan.txt" });
49
- if (ok)
50
- return []; // no findings
51
- const results = JSON.parse(output);
52
- const findings = [];
53
- for (const file of results) {
54
- for (const msg of file.messages ?? []) {
55
- findings.push({
56
- pattern: msg.ruleId.replace("@secretlint/secretlint-rule-", ""),
57
- severity: "high",
58
- category: "secret",
59
- match: msg.message.length > 60 ? msg.message.slice(0, 50) + "..." : msg.message,
60
- index: msg.loc?.start?.offset ?? 0,
61
- });
62
- }
63
- }
64
- return findings;
65
- }
66
- catch {
67
- return [];
68
- }
69
- }
70
13
  // All regex patterns — used for both scanning AND redaction (find-and-replace).
71
- // secretlint adds additional detection on top; deduplication prevents double-counting.
72
14
  const CUSTOM_SECRET_PATTERNS = [
73
15
  // ─── AWS ───────────────────────────────────────────────
74
16
  {
@@ -224,15 +166,7 @@ function regexScan(text) {
224
166
  }
225
167
  return findings;
226
168
  }
227
- /** Scan text for secrets and PII using secretlint + custom regex. */
228
- export async function scanText(text) {
229
- const [slFindings, regexFindings] = await Promise.all([
230
- secretlintScan(text),
231
- Promise.resolve(regexScan(text)),
232
- ]);
233
- return deduplicateFindings([...slFindings, ...regexFindings]);
234
- }
235
- /** Synchronous scan using only custom regex patterns (no secretlint). */
169
+ /** Scan text for secrets and PII using custom regex patterns. */
236
170
  export function scanTextSync(text) {
237
171
  return regexScan(text);
238
172
  }
@@ -277,40 +211,6 @@ export function stripHomePathsInText(text, cwd) {
277
211
  const homeRe = new RegExp(homePattern + "[/\\\\]?", "g");
278
212
  return text.replace(homeRe, "~/");
279
213
  }
280
- // ── Session-level operations ───────────────────────────────────
281
- /** Scan all string fields in a session object for secrets/PII. */
282
- export async function scanSession(session) {
283
- // Collect all string values with their paths
284
- const strings = [];
285
- function walk(value, path) {
286
- if (typeof value === "string" && value.length > 0) {
287
- strings.push({ value, path });
288
- }
289
- else if (Array.isArray(value)) {
290
- for (let i = 0; i < value.length; i++)
291
- walk(value[i], `${path}[${i}]`);
292
- }
293
- else if (value && typeof value === "object") {
294
- for (const [k, v] of Object.entries(value))
295
- walk(v, path ? `${path}.${k}` : k);
296
- }
297
- }
298
- walk(session, "");
299
- // Batch all strings into one scan for secretlint efficiency
300
- const allText = strings.map((s) => s.value).join("\n---FIELD_BOUNDARY---\n");
301
- const allFindings = await scanText(allText);
302
- // Map findings back to field paths
303
- const fieldsWithFindings = [];
304
- let offset = 0;
305
- for (const { value, path } of strings) {
306
- const endOffset = offset + value.length;
307
- const fieldFindings = allFindings.filter((f) => f.index >= offset && f.index < endOffset);
308
- if (fieldFindings.length > 0)
309
- fieldsWithFindings.push(path);
310
- offset = endOffset + "\n---FIELD_BOUNDARY---\n".length;
311
- }
312
- return { findings: allFindings, fieldsWithFindings };
313
- }
314
214
  /** Deep-redact all string fields + strip paths. Returns a new object. */
315
215
  export function redactSession(session, mode = "high", cwd) {
316
216
  return deepRedact(session, mode, cwd);
@@ -25,6 +25,7 @@ export function buildSessionRenderData(opts) {
25
25
  devTake,
26
26
  context: enhanced?.context ?? '',
27
27
  durationMinutes: session.durationMinutes ?? 0,
28
+ wallClockMinutes: session.wallClockMinutes,
28
29
  turns: session.turns ?? 0,
29
30
  filesChanged: session.filesChanged?.length ?? 0,
30
31
  locChanged: session.linesOfCode ?? 0,
@@ -41,7 +42,7 @@ export function buildSessionRenderData(opts) {
41
42
  topFiles: (session.filesChanged ?? []).slice(0, 20).map((f) => typeof f === 'string' ? { path: f, additions: 0, deletions: 0 } : f),
42
43
  recordedAt: sessionRecordedAt,
43
44
  sourceTool,
44
- template: 'editorial',
45
+ template: opts.template ?? 'editorial',
45
46
  agentSummary: agentSummary ?? undefined,
46
47
  },
47
48
  };
@@ -56,15 +57,21 @@ export function buildSessionCard(opts) {
56
57
  const sessionTitle = enhanced?.title ?? session.title;
57
58
  const sessionSkills = enhanced?.skills ?? session.skills ?? [];
58
59
  const sessionRecordedAt = session.date ? new Date(session.date).toISOString() : new Date().toISOString();
60
+ const files = session.filesChanged ?? [];
61
+ const totalAdded = files.reduce((sum, f) => sum + ((typeof f === 'string' ? 0 : f.additions) ?? 0), 0);
62
+ const totalDeleted = files.reduce((sum, f) => sum + ((typeof f === 'string' ? 0 : f.deletions) ?? 0), 0);
59
63
  return {
60
64
  token: sessionId,
61
65
  slug: sessionSlug,
62
66
  title: sessionTitle,
63
67
  devTake,
64
68
  durationMinutes: session.durationMinutes ?? 0,
69
+ wallClockMinutes: session.wallClockMinutes,
65
70
  turns: session.turns ?? 0,
66
71
  locChanged: session.linesOfCode ?? 0,
67
- filesChanged: session.filesChanged?.length ?? 0,
72
+ linesAdded: totalAdded,
73
+ linesDeleted: totalDeleted,
74
+ filesChanged: files.length,
68
75
  skills: sessionSkills,
69
76
  recordedAt: sessionRecordedAt,
70
77
  sourceTool,
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * These functions run on the Node.js server, never in the browser.
9
9
  */
10
- import { renderProject, renderSession } from './liquid.js';
10
+ import { renderPortfolio, renderProject, renderSession } from './liquid.js';
11
11
  /** Errors from the render pipeline carry a machine-readable code. */
12
12
  export class RenderError extends Error {
13
13
  code;
@@ -77,25 +77,52 @@ function validateSession(data) {
77
77
  * @throws {RenderError} with code VALIDATION_ERROR if required fields are missing
78
78
  * @throws {RenderError} with code RENDER_FAILED if Liquid rendering fails
79
79
  */
80
- export function renderProjectHtml(data, extras) {
80
+ export function renderProjectHtml(data, extras, templateName) {
81
81
  validateProject(data);
82
82
  try {
83
- return renderProject(data, extras);
83
+ return renderProject(data, extras, templateName);
84
84
  }
85
85
  catch (err) {
86
86
  throw new RenderError('RENDER_FAILED', `Failed to render project page for ${data.project.slug}`, err);
87
87
  }
88
88
  }
89
+ /**
90
+ * Render a portfolio page to a static HTML fragment.
91
+ *
92
+ * @throws {RenderError} with code VALIDATION_ERROR if required fields are missing
93
+ * @throws {RenderError} with code RENDER_FAILED if Liquid rendering fails
94
+ */
95
+ export function renderPortfolioHtml(data, templateName) {
96
+ const errors = [];
97
+ if (!data.user) {
98
+ errors.push({ field: 'user', message: 'required' });
99
+ }
100
+ else {
101
+ if (!data.user.username)
102
+ errors.push({ field: 'user.username', message: 'required' });
103
+ }
104
+ if (!Array.isArray(data.projects)) {
105
+ errors.push({ field: 'projects', message: 'must be an array' });
106
+ }
107
+ if (errors.length > 0)
108
+ collectErrors(errors);
109
+ try {
110
+ return renderPortfolio(data, templateName);
111
+ }
112
+ catch (err) {
113
+ throw new RenderError('RENDER_FAILED', `Failed to render portfolio page for ${data.user?.username}`, err);
114
+ }
115
+ }
89
116
  /**
90
117
  * Render a session page to a static HTML fragment.
91
118
  *
92
119
  * @throws {RenderError} with code VALIDATION_ERROR if required fields are missing
93
120
  * @throws {RenderError} with code RENDER_FAILED if Liquid rendering fails
94
121
  */
95
- export function renderSessionHtml(data) {
122
+ export function renderSessionHtml(data, templateName) {
96
123
  validateSession(data);
97
124
  try {
98
- return renderSession(data);
125
+ return renderSession(data, templateName);
99
126
  }
100
127
  catch (err) {
101
128
  throw new RenderError('RENDER_FAILED', `Failed to render session page for ${data.session.token}`, err);
@@ -7,6 +7,8 @@
7
7
  import { Liquid } from 'liquidjs';
8
8
  import { dirname, resolve } from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
+ import { getTemplateInfo } from './templates.js';
11
+ import { escapeHtml } from '../format-utils.js';
10
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
11
13
  const engine = new Liquid({
12
14
  root: resolve(__dirname, 'templates'),
@@ -56,7 +58,7 @@ engine.registerFilter('formatDateShort', (iso) => {
56
58
  }
57
59
  });
58
60
  engine.registerFilter('jsonAttr', (value) => {
59
- return JSON.stringify(value);
61
+ return escapeHtml(JSON.stringify(value));
60
62
  });
61
63
  engine.registerFilter('localeNumber', (value) => {
62
64
  return value.toLocaleString();
@@ -69,11 +71,20 @@ const DURATION_COLORS = ['primary', 'green', 'violet'];
69
71
  engine.registerFilter('durationColor', (index) => {
70
72
  return DURATION_COLORS[index % DURATION_COLORS.length];
71
73
  });
72
- export function renderProject(data, extras) {
74
+ const DEFAULT_TEMPLATE = 'editorial';
75
+ /** Inject data-accent and data-mode into the first data-template wrapper element. */
76
+ function injectTemplateAttrs(html, templateName) {
77
+ const info = getTemplateInfo(templateName);
78
+ if (!info)
79
+ return html;
80
+ return html.replace(/data-template="[^"]*"/, (match) => `${match} data-accent="${escapeHtml(info.accent)}" data-mode="${info.mode}"`);
81
+ }
82
+ export function renderProject(data, extras, templateName) {
83
+ const template = templateName || DEFAULT_TEMPLATE;
73
84
  // Pre-compute derived data for the template
74
85
  const allSessions = data.allSessions || data.sessions;
75
86
  const sourceCounts = {};
76
- for (const s of data.sessions) {
87
+ for (const s of allSessions) {
77
88
  const src = s.sourceTool || 'unknown';
78
89
  sourceCounts[src] = (sourceCounts[src] || 0) + 1;
79
90
  }
@@ -82,7 +93,8 @@ export function renderProject(data, extras) {
82
93
  const chartSessions = (extras?.fullSessions ?? allSessions.map((s) => ({
83
94
  id: s.token, slug: s.slug, title: s.title, date: s.recordedAt,
84
95
  durationMinutes: s.durationMinutes, turns: s.turns,
85
- linesOfCode: s.locChanged, status: 'enhanced',
96
+ linesOfCode: s.locChanged, linesAdded: s.linesAdded, linesDeleted: s.linesDeleted,
97
+ status: 'enhanced',
86
98
  projectName: data.project.title, rawLog: [],
87
99
  skills: s.skills, source: s.sourceTool,
88
100
  filesChanged: s.filesChanged,
@@ -93,6 +105,45 @@ export function renderProject(data, extras) {
93
105
  // Encode JSON safe for single-quoted HTML attributes
94
106
  const sessionsJson = JSON.stringify(chartSessions).replace(/'/g, '&#39;');
95
107
  const growthJson = sessionsJson; // same data for both charts
108
+ // Pre-compute chart coordinates for Liquid-rendered SVGs
109
+ // Sort all sessions by date, compress gaps, compute x positions
110
+ const sortedAll = [...(data.allSessions || data.sessions)]
111
+ .filter(s => s.recordedAt)
112
+ .sort((a, b) => new Date(a.recordedAt).getTime() - new Date(b.recordedAt).getTime());
113
+ // Gap compression — must match GrowthChart.tsx constants
114
+ const GAP_THRESHOLD = 60 * 60 * 1000; // 1 hour
115
+ const COMPRESSED_GAP = 10 * 60 * 1000; // 10 minutes visual
116
+ const visualTimes = [0];
117
+ for (let i = 1; i < sortedAll.length; i++) {
118
+ const gap = new Date(sortedAll[i].recordedAt).getTime() - new Date(sortedAll[i - 1].recordedAt).getTime();
119
+ visualTimes.push(visualTimes[i - 1] + (gap > GAP_THRESHOLD ? COMPRESSED_GAP : Math.max(gap, 0)));
120
+ }
121
+ const totalVisualTime = visualTimes[visualTimes.length - 1] || 1;
122
+ // Compute x positions (0-1000 range, template will scale to SVG width)
123
+ // Also compute cumulative additions/deletions for growth chart
124
+ let cumAdded = 0;
125
+ let cumDeleted = 0;
126
+ const chartPoints = sortedAll.map((s, i) => {
127
+ cumAdded += s.linesAdded || 0;
128
+ cumDeleted += s.linesDeleted || 0;
129
+ return {
130
+ title: s.title,
131
+ slug: s.slug,
132
+ date: s.recordedAt,
133
+ locChanged: s.locChanged,
134
+ linesAdded: s.linesAdded || 0,
135
+ linesDeleted: s.linesDeleted || 0,
136
+ durationMinutes: s.durationMinutes,
137
+ sourceTool: s.sourceTool || 'unknown',
138
+ cumAdded,
139
+ cumDeleted,
140
+ // x position as integer 0-1000 (template divides by 1000 and multiplies by plot width)
141
+ xPct: Math.round((visualTimes[i] / totalVisualTime) * 1000),
142
+ };
143
+ });
144
+ // SVG width hint: wider for more sessions (min 1200, scale with count)
145
+ // Match React GrowthChart sizing: base on compressed time, not raw session count
146
+ const chartSvgWidth = Math.max(700, Math.round(totalVisualTime / 60000 * 0.8) + 120);
96
147
  const durationLabel = data.project.totalAgentDurationMinutes ? 'Human / Agents' : 'Time';
97
148
  const efficiencyMultiplier = data.project.totalAgentDurationMinutes && data.project.totalDurationMinutes > 0
98
149
  ? (data.project.totalAgentDurationMinutes / data.project.totalDurationMinutes)
@@ -142,17 +193,106 @@ export function renderProject(data, extras) {
142
193
  seen.add(s.token);
143
194
  return true;
144
195
  }).slice(0, 6);
145
- return engine.renderFileSync('project', {
196
+ const sessionSuffix = data.sessionBaseUrl ? '.html' : '';
197
+ const html = engine.renderFileSync(`${template}/project`, {
146
198
  ...data,
147
199
  arc: extras?.arc ?? [],
148
200
  featuredSessions,
201
+ chartPoints,
202
+ chartSvgWidth,
149
203
  sourceCounts: Object.entries(sourceCounts).map(([tool, count]) => ({ tool, count })),
150
204
  sessionsJson,
151
205
  growthJson,
152
206
  durationLabel,
153
207
  efficiencyMultiplier: efficiencyStr,
208
+ sessionSuffix,
154
209
  });
210
+ return injectTemplateAttrs(html, template);
155
211
  }
156
- export function renderSession(data) {
157
- return engine.renderFileSync('session', data);
212
+ export function renderSession(data, templateName) {
213
+ const template = templateName || DEFAULT_TEMPLATE;
214
+ return injectTemplateAttrs(engine.renderFileSync(`${template}/session`, data), template);
215
+ }
216
+ export function renderPortfolio(data, templateName) {
217
+ const template = templateName || DEFAULT_TEMPLATE;
218
+ const durationLabel = data.totalAgentDurationMinutes ? 'Human / Agents' : 'Time';
219
+ const efficiencyMultiplier = data.totalAgentDurationMinutes && data.totalDurationMinutes > 0
220
+ ? (data.totalAgentDurationMinutes / data.totalDurationMinutes)
221
+ : undefined;
222
+ const efficiencyStr = efficiencyMultiplier && efficiencyMultiplier > 1 ? `${efficiencyMultiplier.toFixed(1)}x` : undefined;
223
+ const u = data.user;
224
+ const hasProfile = !!(u.displayName || u.bio || u.photoUrl || u.location || u.email || u.phone || u.linkedinUrl || u.githubUrl || u.twitterHandle || u.websiteUrl || u.resumeUrl);
225
+ // Aggregate skills across all projects with counts
226
+ const skillMap = new Map();
227
+ for (const p of data.projects) {
228
+ for (const s of p.skills) {
229
+ skillMap.set(s, (skillMap.get(s) || 0) + 1);
230
+ }
231
+ }
232
+ const allSkills = [...skillMap.entries()]
233
+ .map(([name, projectCount]) => ({ name, projectCount }))
234
+ .sort((a, b) => b.projectCount - a.projectCount);
235
+ const topSkills = allSkills.slice(0, 8);
236
+ // Aggregate source counts across all projects
237
+ const sourceMap = new Map();
238
+ for (const p of data.projects) {
239
+ for (const sc of p.sourceCounts || []) {
240
+ sourceMap.set(sc.tool, (sourceMap.get(sc.tool) || 0) + sc.count);
241
+ }
242
+ }
243
+ const sourceCounts = [...sourceMap.entries()]
244
+ .map(([tool, count]) => ({ tool, count }))
245
+ .sort((a, b) => b.count - a.count);
246
+ const totalSourceSessions = sourceCounts.reduce((sum, s) => sum + s.count, 0);
247
+ const activityByDay = computeActivityByDay(data.projects);
248
+ const activityByMonth = computeActivityByMonth(data.projects);
249
+ return injectTemplateAttrs(engine.renderFileSync(`${template}/portfolio`, {
250
+ ...data,
251
+ durationLabel,
252
+ efficiencyMultiplier: efficiencyStr,
253
+ hasProfile,
254
+ allSkills,
255
+ topSkills,
256
+ sourceCounts,
257
+ totalSourceSessions,
258
+ activityByDay,
259
+ activityByMonth,
260
+ }), template);
261
+ }
262
+ export function computeActivityByDay(projects) {
263
+ const dayMap = new Map();
264
+ for (const p of projects) {
265
+ for (const s of p.sessions || []) {
266
+ const day = s.date.slice(0, 10);
267
+ const existing = dayMap.get(day) || { count: 0, loc: 0 };
268
+ dayMap.set(day, { count: existing.count + 1, loc: existing.loc + s.loc });
269
+ }
270
+ }
271
+ return [...dayMap.entries()]
272
+ .map(([date, d]) => ({ date, ...d }))
273
+ .sort((a, b) => a.date.localeCompare(b.date));
274
+ }
275
+ export function computeActivityByMonth(projects) {
276
+ const monthMap = new Map();
277
+ for (const p of projects) {
278
+ for (const s of p.sessions || []) {
279
+ const monthKey = s.date.slice(0, 7);
280
+ const existing = monthMap.get(monthKey) || { sessions: 0, loc: 0, projects: new Map() };
281
+ existing.sessions++;
282
+ existing.loc += s.loc;
283
+ const projData = existing.projects.get(p.title) || { sessions: 0, loc: 0 };
284
+ projData.sessions++;
285
+ projData.loc += s.loc;
286
+ existing.projects.set(p.title, projData);
287
+ monthMap.set(monthKey, existing);
288
+ }
289
+ }
290
+ return [...monthMap.entries()]
291
+ .sort(([a], [b]) => a.localeCompare(b))
292
+ .map(([key, d]) => ({
293
+ month: new Date(key + '-01').toLocaleDateString('en-US', { month: 'short' }),
294
+ sessions: d.sessions,
295
+ loc: d.loc,
296
+ projects: [...d.projects.entries()].map(([name, pd]) => ({ name, ...pd })),
297
+ }));
158
298
  }