project-logbook 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/commands/build.js +54 -76
  2. package/dist/commands/init.js +11 -11
  3. package/dist/commands/lint.js +27 -10
  4. package/dist/commands/list.js +18 -18
  5. package/dist/commands/log.js +5 -5
  6. package/dist/commands/new.js +19 -20
  7. package/dist/commands/preview.js +9 -8
  8. package/dist/commands/release.js +6 -6
  9. package/dist/commands/start.js +26 -21
  10. package/dist/commands/status.d.ts +1 -0
  11. package/dist/commands/status.js +138 -0
  12. package/dist/commands/steer.js +9 -9
  13. package/dist/commands/upgrade.js +5 -5
  14. package/dist/index.js +9 -8
  15. package/dist/lib/build-helpers.js +30 -67
  16. package/dist/lib/build-steps.d.ts +20 -0
  17. package/dist/lib/build-steps.js +57 -0
  18. package/dist/lib/config.d.ts +17 -0
  19. package/dist/lib/config.js +27 -3
  20. package/dist/lib/entry-id.d.ts +22 -0
  21. package/dist/lib/entry-id.js +26 -0
  22. package/dist/lib/entry-paths.d.ts +23 -0
  23. package/dist/lib/entry-paths.js +55 -0
  24. package/dist/lib/git-helpers.d.ts +32 -1
  25. package/dist/lib/git-helpers.js +119 -26
  26. package/dist/lib/hast-helpers.d.ts +10 -0
  27. package/dist/lib/hast-helpers.js +22 -0
  28. package/dist/lib/html-attributes.d.ts +17 -0
  29. package/dist/lib/html-attributes.js +17 -0
  30. package/dist/lib/html-escape.d.ts +16 -0
  31. package/dist/lib/html-escape.js +38 -0
  32. package/dist/lib/image-helpers.js +26 -33
  33. package/dist/lib/lint-runner.js +5 -5
  34. package/dist/lib/markdown-processors.d.ts +22 -0
  35. package/dist/lib/markdown-processors.js +68 -0
  36. package/dist/lib/package-version.d.ts +5 -0
  37. package/dist/lib/package-version.js +16 -0
  38. package/dist/lib/rss.d.ts +29 -0
  39. package/dist/lib/rss.js +77 -0
  40. package/dist/lib/styles.js +5 -2
  41. package/dist/lib/template-helpers.d.ts +4 -3
  42. package/dist/lib/template-helpers.js +51 -27
  43. package/dist/lib/template-types.d.ts +2 -2
  44. package/dist/lib/templates.d.ts +12 -3
  45. package/dist/lib/templates.js +59 -52
  46. package/dist/lib/theme.d.ts +37 -0
  47. package/dist/lib/theme.js +50 -0
  48. package/dist/lib/url-helpers.d.ts +13 -0
  49. package/dist/lib/url-helpers.js +27 -0
  50. package/dist/linters/diff-to-narrative.d.ts +6 -0
  51. package/dist/linters/diff-to-narrative.js +114 -0
  52. package/dist/linters/index.js +4 -0
  53. package/dist/linters/technical-log.d.ts +7 -0
  54. package/dist/linters/technical-log.js +72 -0
  55. package/dist/templates/CONTRIBUTING.md +12 -3
  56. package/dist/templates/index.md +10 -6
  57. package/dist/templates/log.md +5 -0
  58. package/dist/templates/logbook-client.js +42 -16
  59. package/dist/templates/steer.txt +21 -5
  60. package/dist/templates/styles.css +121 -0
  61. package/dist/utils/date.d.ts +30 -1
  62. package/dist/utils/date.js +68 -15
  63. package/dist/utils/frontmatter.d.ts +26 -0
  64. package/dist/utils/frontmatter.js +37 -0
  65. package/dist/utils/fs.d.ts +13 -0
  66. package/dist/utils/fs.js +23 -0
  67. package/dist/utils/log-timeline.d.ts +69 -0
  68. package/dist/utils/log-timeline.js +218 -0
  69. package/package.json +4 -2
  70. package/src/templates/CONTRIBUTING.md +12 -3
  71. package/src/templates/index.md +10 -6
  72. package/src/templates/log.md +5 -0
  73. package/src/templates/logbook-client.js +42 -16
  74. package/src/templates/steer.txt +21 -5
  75. package/src/templates/styles.css +121 -0
@@ -1,4 +1,4 @@
1
- import chalk from 'chalk';
1
+ import { error, info, warning } from './theme.js';
2
2
  import { linters } from '../linters/index.js';
3
3
  export async function runLinters(context) {
4
4
  const issues = [];
@@ -8,23 +8,23 @@ export async function runLinters(context) {
8
8
  issues.push(...result);
9
9
  }
10
10
  catch (err) {
11
- console.error(chalk.red(`Error running linter '${linter.name}':`), err);
11
+ console.error(error(`Error running linter '${linter.name}':`), err);
12
12
  }
13
13
  }
14
14
  if (issues.length === 0) {
15
15
  return true;
16
16
  }
17
17
  if (context.entryName) {
18
- console.log(chalk.blue(`\nEntry: ${context.entryName}`));
18
+ console.log(info(`\nEntry: ${context.entryName}`));
19
19
  }
20
20
  let hasErrors = false;
21
21
  for (const issue of issues) {
22
22
  if (issue.level === 'error') {
23
- console.error(chalk.red(` [${issue.category.toUpperCase()}] ${issue.message}`));
23
+ console.error(error(` [${issue.category.toUpperCase()}] ${issue.message}`));
24
24
  hasErrors = true;
25
25
  }
26
26
  else {
27
- console.warn(chalk.yellow(` [${issue.category.toUpperCase()}] ${issue.message}`));
27
+ console.warn(warning(` [${issue.category.toUpperCase()}] ${issue.message}`));
28
28
  }
29
29
  }
30
30
  return !hasErrors;
@@ -0,0 +1,22 @@
1
+ import { unified } from 'unified';
2
+ import type { Plugin } from 'unified';
3
+ import type { Root } from 'hast';
4
+ /**
5
+ * Rehype plugin: rewrite relative .md links to /index.html equivalents.
6
+ */
7
+ export declare const rehypeRewriteMdLinks: Plugin<[], Root>;
8
+ /**
9
+ * General markdown processor for project files (README.md, etc.)
10
+ * Used for files that don't need image path rewriting
11
+ */
12
+ export declare function createGeneralMarkdownProcessor(rehypeRewriteMdLinks: Plugin<[], Root>): ReturnType<typeof unified.prototype.use>;
13
+ /**
14
+ * Entry markdown processor with image path rewriting
15
+ * Used for logbook entry content (index.md, ticket.md, log.md)
16
+ */
17
+ export declare function createEntryMarkdownProcessor(rehypeRewriteMdLinks: Plugin<[], Root>, rehypeRewriteImagePaths: Plugin<[], Root>): ReturnType<typeof unified.prototype.use>;
18
+ /**
19
+ * Image collection processor
20
+ * Collects all image paths during markdown-to-html conversion
21
+ */
22
+ export declare function createImageCollectionProcessor(imageCollectionPlugin: Plugin<[], Root>, rehypeRewriteImagePaths: Plugin<[], Root>, rehypeRewriteMdLinks?: Plugin<[], Root>): ReturnType<typeof unified.prototype.use>;
@@ -0,0 +1,68 @@
1
+ import { unified } from 'unified';
2
+ import remarkParse from 'remark-parse';
3
+ import remarkGfm from 'remark-gfm';
4
+ import remarkRehype from 'remark-rehype';
5
+ import rehypeSlug from 'rehype-slug';
6
+ import rehypeFormat from 'rehype-format';
7
+ import rehypeStringify from 'rehype-stringify';
8
+ import { visitHastElements } from './hast-helpers.js';
9
+ import { isExternalUrlOrAnchor } from './url-helpers.js';
10
+ /**
11
+ * Rehype plugin: rewrite relative .md links to /index.html equivalents.
12
+ */
13
+ export const rehypeRewriteMdLinks = () => {
14
+ return (tree) => {
15
+ visitHastElements(tree, 'a', (node) => {
16
+ const href = node.properties?.href;
17
+ if (typeof href !== 'string')
18
+ return;
19
+ if (isExternalUrlOrAnchor(href))
20
+ return;
21
+ const mdMatch = href.match(/^(.*?)\.md(#.*)?$/i);
22
+ if (!mdMatch)
23
+ return;
24
+ const base = mdMatch[1];
25
+ const fragment = mdMatch[2] ?? '';
26
+ node.properties.href = `${base}/index.html${fragment}`;
27
+ });
28
+ };
29
+ };
30
+ /**
31
+ * Create a markdown processor with custom rehype plugins
32
+ *
33
+ * @param plugins - Array of rehype plugins to inject before formatting
34
+ * @returns A unified processor
35
+ */
36
+ function createMarkdownProcessor(plugins = []) {
37
+ const processor = unified().use(remarkParse).use(remarkGfm).use(remarkRehype);
38
+ // Apply custom plugins before formatting
39
+ for (const plugin of plugins) {
40
+ processor.use(plugin);
41
+ }
42
+ return processor.use(rehypeSlug).use(rehypeFormat).use(rehypeStringify);
43
+ }
44
+ /**
45
+ * General markdown processor for project files (README.md, etc.)
46
+ * Used for files that don't need image path rewriting
47
+ */
48
+ export function createGeneralMarkdownProcessor(rehypeRewriteMdLinks) {
49
+ return createMarkdownProcessor([rehypeRewriteMdLinks]);
50
+ }
51
+ /**
52
+ * Entry markdown processor with image path rewriting
53
+ * Used for logbook entry content (index.md, ticket.md, log.md)
54
+ */
55
+ export function createEntryMarkdownProcessor(rehypeRewriteMdLinks, rehypeRewriteImagePaths) {
56
+ return createMarkdownProcessor([rehypeRewriteMdLinks, rehypeRewriteImagePaths]);
57
+ }
58
+ /**
59
+ * Image collection processor
60
+ * Collects all image paths during markdown-to-html conversion
61
+ */
62
+ export function createImageCollectionProcessor(imageCollectionPlugin, rehypeRewriteImagePaths, rehypeRewriteMdLinks) {
63
+ const plugins = [imageCollectionPlugin, rehypeRewriteImagePaths];
64
+ if (rehypeRewriteMdLinks) {
65
+ plugins.unshift(rehypeRewriteMdLinks);
66
+ }
67
+ return createMarkdownProcessor(plugins);
68
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Get the package version from package.json
3
+ * This centralizes version reading across the CLI to avoid duplication and path inconsistencies
4
+ */
5
+ export declare function getPackageVersion(): string;
@@ -0,0 +1,16 @@
1
+ import fs from 'fs-extra';
2
+ import { join, dirname } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ /**
5
+ * Get the package version from package.json
6
+ * This centralizes version reading across the CLI to avoid duplication and path inconsistencies
7
+ */
8
+ export function getPackageVersion() {
9
+ // Get the directory of this file (src/lib/)
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = dirname(__filename);
12
+ // Navigate from src/lib to dist/lib (compiled) or keep as-is for ESM
13
+ const pkgPath = join(__dirname, '../../package.json');
14
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
15
+ return pkg.version || '0.0.0';
16
+ }
@@ -0,0 +1,29 @@
1
+ import type { TimelineEntry } from './template-types.js';
2
+ /**
3
+ * Configuration for RSS feed generation.
4
+ * Used by generateRssFeed() to create properly formatted RSS 2.0 feeds.
5
+ */
6
+ interface RssFeedConfig {
7
+ title: string;
8
+ description: string;
9
+ language: string;
10
+ feed_url: string;
11
+ site_url: string;
12
+ image_url?: string;
13
+ generator?: string;
14
+ }
15
+ /**
16
+ * Generate RSS 2.0 feed XML from logbook entries
17
+ * @param entries - Array of timeline entries (will use latest 50)
18
+ * @param config - RSS feed configuration
19
+ * @param baseUrl - Base URL for the site (used for absolute feed URLs)
20
+ * @returns RSS XML string
21
+ */
22
+ export declare function generateRssFeed(entries: TimelineEntry[], config: RssFeedConfig, baseUrl?: string): string;
23
+ /**
24
+ * Validate RSS XML structure (basic validation)
25
+ * @param xml - RSS XML string
26
+ * @returns true if valid RSS structure
27
+ */
28
+ export declare function validateRssXml(xml: string): boolean;
29
+ export {};
@@ -0,0 +1,77 @@
1
+ import RSS from 'rss';
2
+ import { formatDateTimeForRss } from '../utils/date.js';
3
+ /**
4
+ * Generate RSS 2.0 feed XML from logbook entries
5
+ * @param entries - Array of timeline entries (will use latest 50)
6
+ * @param config - RSS feed configuration
7
+ * @param baseUrl - Base URL for the site (used for absolute feed URLs)
8
+ * @returns RSS XML string
9
+ */
10
+ export function generateRssFeed(entries, config, baseUrl = '') {
11
+ // Use only the latest 50 entries
12
+ const limitedEntries = entries.slice(0, 50);
13
+ const feed = new RSS({
14
+ title: config.title,
15
+ description: config.description,
16
+ language: config.language,
17
+ feed_url: config.feed_url,
18
+ site_url: config.site_url,
19
+ image_url: config.image_url,
20
+ generator: config.generator,
21
+ ttl: 60, // Time to live in minutes
22
+ });
23
+ for (const entry of limitedEntries) {
24
+ const itemUrl = `${baseUrl}${entry.slug}/`;
25
+ const pubDate = formatDateTimeForRss(entry.dateStart);
26
+ feed.item({
27
+ title: entry.title || entry.slug,
28
+ description: stripMarkdown(entry.summary),
29
+ url: itemUrl,
30
+ guid: entry.slug,
31
+ date: pubDate,
32
+ author: entry.prompter || undefined,
33
+ });
34
+ }
35
+ return feed.xml({ indent: true });
36
+ }
37
+ /**
38
+ * Strip markdown formatting from text for RSS description
39
+ * Removes bold, italic, code, and heading markers
40
+ */
41
+ function stripMarkdown(text) {
42
+ return text
43
+ .replace(/`([^`]+)`/g, '$1') // Remove inline code backticks
44
+ .replace(/\*\*([^*]+)\*\*/g, '$1') // Remove bold
45
+ .replace(/\*([^*]+)\*/g, '$1') // Remove italic
46
+ .replace(/#{1,6}\s+/g, '') // Remove heading markers
47
+ .replace(/\n+/g, ' ') // Replace newlines with spaces
48
+ .trim();
49
+ }
50
+ /**
51
+ * Validate RSS XML structure (basic validation)
52
+ * @param xml - RSS XML string
53
+ * @returns true if valid RSS structure
54
+ */
55
+ export function validateRssXml(xml) {
56
+ try {
57
+ // Basic structure checks
58
+ if (!xml.includes('<?xml version'))
59
+ return false;
60
+ if (!xml.includes('<rss'))
61
+ return false;
62
+ if (!xml.includes('<channel>'))
63
+ return false;
64
+ if (!xml.includes('</channel>'))
65
+ return false;
66
+ if (!xml.includes('<item>'))
67
+ return false;
68
+ // Check for required RSS 2.0 elements
69
+ const hasTitle = xml.includes('<title>');
70
+ const hasLink = xml.includes('<link>');
71
+ const hasDescription = xml.includes('<description>');
72
+ return hasTitle && hasLink && hasDescription;
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ }
@@ -1,13 +1,16 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { join, dirname } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { isValidHexColor } from './html-escape.js';
4
5
  const __dirname = dirname(fileURLToPath(import.meta.url));
5
6
  export function getStyles(primaryColor) {
6
7
  const cssPath = join(__dirname, '../templates/styles.css');
7
8
  const staticCss = readFileSync(cssPath, 'utf8');
9
+ // Validate the primary color to prevent CSS injection
10
+ const validatedColor = isValidHexColor(primaryColor) ? primaryColor : '#2563eb';
8
11
  const root = `:root {
9
- --primary: ${primaryColor};
10
- --primary-soft: ${primaryColor}15;
12
+ --primary: ${validatedColor};
13
+ --primary-soft: ${validatedColor}15;
11
14
  --bg: #fdfdfd;
12
15
  --text: #1a1a1a;
13
16
  --text-muted: #666666;
@@ -1,8 +1,9 @@
1
1
  import type { TimelineEntry, TimelineCommitItem, TimelineTagItem, EntryLink, GitCommit } from './template-types.js';
2
+ export declare const html: (strings: TemplateStringsArray, ...values: unknown[]) => string;
2
3
  export declare const navLink: (entry: EntryLink, dir: "prev" | "next") => string;
3
4
  export declare const renderTagsAndWorkspaces: (tags: string[] | undefined, workspaces: string[] | undefined) => string;
4
- export declare const renderCommits: (commits: GitCommit[] | undefined) => string;
5
+ export declare const renderCommits: (commits: GitCommit[] | undefined, repositoryUrl?: string) => string;
5
6
  export declare const renderTimelineEntryItem: (e: TimelineEntry) => string;
6
- export declare const renderTimelineCommitItem: (c: TimelineCommitItem, jiraBaseUrl?: string, jiraPrefix?: string) => string;
7
- export declare const renderTimelineTagItem: (t: TimelineTagItem) => string;
7
+ export declare const renderTimelineCommitItem: (c: TimelineCommitItem, jiraBaseUrl?: string, jiraPrefix?: string, repositoryUrl?: string) => string;
8
+ export declare const renderTimelineTagItem: (t: TimelineTagItem, repositoryUrl?: string) => string;
8
9
  export declare const paginationLinks: (currentPage: number, totalPages: number) => string;
@@ -1,20 +1,26 @@
1
1
  import { linkJiraIds } from './jira-helpers.js';
2
- const html = (strings, ...values) => {
2
+ import { formatAbsoluteDate } from '../utils/date.js';
3
+ import { escapeHtml } from './html-escape.js';
4
+ import { getCommitUrl, getTagUrl } from './git-helpers.js';
5
+ import { ATTR_ENTRY_SLUG, ATTR_DATA_DATE, CLASS_COMMIT_SHA, CLASS_COMMIT_TIME, CLASS_COMMIT_MESSAGE, } from './html-attributes.js';
6
+ export const html = (strings, ...values) => {
3
7
  return strings.reduce((acc, str, i) => acc + str + (values[i] ?? ''), '');
4
8
  };
5
9
  export const navLink = (entry, dir) => {
6
10
  const label = dir === 'prev' ? '← Previous' : 'Next →';
7
- const t = `${entry.ticket ? `${entry.ticket}: ` : ''}${entry.title}`;
11
+ const ticket = entry.ticket ? escapeHtml(entry.ticket) : '';
12
+ const title = escapeHtml(entry.title);
13
+ const t = `${ticket ? `${ticket}: ` : ''}${title}`;
8
14
  return `<a href="../${entry.slug}/index.html" class="post-nav-link"><span class="post-nav-label">${label}</span><span class="post-nav-title">${t}</span></a>`;
9
15
  };
10
16
  const renderWorkspaces = (workspaces) => {
11
17
  if (!workspaces || workspaces.length === 0)
12
18
  return '';
13
- return workspaces.map((ws) => `<span class="tag-badge tag-workspace">${ws}</span>`).join('');
19
+ return workspaces.map((ws) => `<span class="tag-badge tag-workspace">${escapeHtml(ws)}</span>`).join('');
14
20
  };
15
21
  export const renderTagsAndWorkspaces = (tags, workspaces) => {
16
22
  const tagsHtml = tags && tags.length > 0
17
- ? tags.map((tag) => `<span class="tag-badge tag-${tag.replace('#', '')}">${tag}</span>`).join('')
23
+ ? tags.map((tag) => `<span class="tag-badge tag-${tag.replace('#', '')}">${escapeHtml(tag)}</span>`).join('')
18
24
  : '';
19
25
  const wsHtml = renderWorkspaces(workspaces);
20
26
  if (!tagsHtml && !wsHtml)
@@ -22,47 +28,65 @@ export const renderTagsAndWorkspaces = (tags, workspaces) => {
22
28
  return html `<div class="tags-wrapper">${tagsHtml}${wsHtml}</div>`;
23
29
  };
24
30
  // Renders a compact list of git commits to embed in the Technical Log tab.
25
- export const renderCommits = (commits) => {
31
+ export const renderCommits = (commits, repositoryUrl) => {
26
32
  if (!commits || commits.length === 0)
27
33
  return '';
28
34
  const rows = commits
29
- .map((c) => `<div class="commit-item"><span class="commit-sha">${c.sha}</span><span class="commit-message">${c.message}</span><span class="commit-time" title="${c.timestamp}">${c.relativeTime}</span></div>`)
35
+ .map((c) => {
36
+ const formatted = formatAbsoluteDate(c.timestamp);
37
+ const sha = escapeHtml(c.sha);
38
+ const message = escapeHtml(c.message);
39
+ const commitUrl = getCommitUrl(repositoryUrl, c.sha);
40
+ const shaHtml = commitUrl
41
+ ? `<a href="${escapeHtml(commitUrl)}" class="${CLASS_COMMIT_SHA}" target="_blank" rel="noopener noreferrer">${sha}</a>`
42
+ : `<span class="${CLASS_COMMIT_SHA}">${sha}</span>`;
43
+ return `<div class="commit-item">${shaHtml}<span class="${CLASS_COMMIT_MESSAGE}">${message}</span><span class="${CLASS_COMMIT_TIME}" title="${c.timestamp}" ${ATTR_DATA_DATE}="${c.timestamp}">${formatted}</span></div>`;
44
+ })
30
45
  .join('');
31
46
  return `<div class="commit-list"><h3 class="commit-list-heading">Git Commits</h3>${rows}</div>`;
32
47
  };
33
- export const renderTimelineEntryItem = (e) => html `<div class="timeline-item" data-entry-slug="${e.slug}">
48
+ export const renderTimelineEntryItem = (e) => {
49
+ const ticket = e.ticket ? escapeHtml(e.ticket) : '';
50
+ const title = e.title ? escapeHtml(e.title) : '';
51
+ const llm = e.llm ? escapeHtml(e.llm) : '';
52
+ const harness = e.harness ? escapeHtml(e.harness) : '';
53
+ const prompter = e.prompter ? escapeHtml(e.prompter) : '';
54
+ const summary = e.summary ? escapeHtml(e.summary) : '';
55
+ return html `<div class="timeline-item" ${ATTR_ENTRY_SLUG}="${e.slug}">
34
56
  <a class="timeline-card" href="./${e.slug}/index.html"
35
57
  ><div class="item-content">
36
58
  <div class="item-meta">
37
- <span data-date="${e.dateStart}">${e.displayDate}</span> • <span class="sort-time">${e.sortTime}</span> •
38
- ${e.llm ? ` ${e.llm} via ` : ''}${e.harness}${e.prompter ? ` / ${e.prompter}` : ''}
59
+ <span ${ATTR_DATA_DATE}="${e.dateStart}">${e.displayDate}</span> •
60
+ ${llm ? ` ${llm} via ` : ''}${harness}${prompter ? ` / ${prompter}` : ''}
39
61
  </div>
40
62
  <h3 class="item-title">
41
- <span class="new-badge" style="display:none;">NEW</span>${e.ticket ? `${e.ticket}: ` : ''}${e.title}
63
+ <span class="new-badge" style="display:none;">NEW</span>${ticket ? `${ticket}: ` : ''}${title}
42
64
  </h3>
43
- <div class="item-summary">${e.summary}</div>
65
+ <div class="item-summary">${summary}</div>
44
66
  ${renderTagsAndWorkspaces(e.tags, e.workspaces)}
45
67
  </div>
46
68
  <span class="item-arrow">›</span></a
47
69
  >
48
70
  </div>`;
49
- export const renderTimelineCommitItem = (c, jiraBaseUrl, jiraPrefix) => {
50
- const message = jiraBaseUrl && jiraPrefix ? linkJiraIds(c.message, jiraBaseUrl, jiraPrefix) : c.message;
51
- return `<div class="timeline-item timeline-item--commit"><div class="commit-chip"><span class="commit-sha">${c.sha}</span><span class="commit-message">${message}</span><span class="commit-time" title="${c.timestamp}" data-date="${c.timestamp}">${c.relativeTime}</span></div></div>`;
52
71
  };
53
- export const renderTimelineTagItem = (t) => {
54
- const formatted = new Date(t.timestamp)
55
- .toLocaleString('de-DE', {
56
- year: 'numeric',
57
- month: '2-digit',
58
- day: '2-digit',
59
- hour: '2-digit',
60
- minute: '2-digit',
61
- second: '2-digit',
62
- hour12: false,
63
- })
64
- .replace(',', '');
65
- return `<div class="timeline-item timeline-item--tag"><div class="tag-chip"><span class="tag-chip-icon">🏷</span><span class="tag-chip-name">${t.name}</span><span class="tag-chip-date" title="${t.timestamp}">${formatted}</span></div></div>`;
72
+ export const renderTimelineCommitItem = (c, jiraBaseUrl, jiraPrefix, repositoryUrl) => {
73
+ const message = jiraBaseUrl && jiraPrefix ? linkJiraIds(c.message, jiraBaseUrl, jiraPrefix) : escapeHtml(c.message);
74
+ const formatted = formatAbsoluteDate(c.timestamp);
75
+ const sha = escapeHtml(c.sha);
76
+ const commitUrl = getCommitUrl(repositoryUrl, c.sha);
77
+ const shaHtml = commitUrl
78
+ ? `<a href="${escapeHtml(commitUrl)}" class="${CLASS_COMMIT_SHA}" target="_blank" rel="noopener noreferrer">${sha}</a>`
79
+ : `<span class="${CLASS_COMMIT_SHA}">${sha}</span>`;
80
+ return `<div class="timeline-item timeline-item--commit"><div class="commit-chip">${shaHtml}<span class="${CLASS_COMMIT_MESSAGE}">${message}</span><span class="${CLASS_COMMIT_TIME}" title="${c.timestamp}" ${ATTR_DATA_DATE}="${c.timestamp}">${formatted}</span></div></div>`;
81
+ };
82
+ export const renderTimelineTagItem = (t, repositoryUrl) => {
83
+ const formatted = formatAbsoluteDate(t.timestamp);
84
+ const name = escapeHtml(t.name);
85
+ const tagUrl = getTagUrl(repositoryUrl, t.name);
86
+ const tagHtml = tagUrl
87
+ ? `<a href="${escapeHtml(tagUrl)}" class="tag-chip-name" target="_blank" rel="noopener noreferrer">${name}</a>`
88
+ : `<span class="tag-chip-name">${name}</span>`;
89
+ return `<div class="timeline-item timeline-item--tag"><div class="tag-chip"><span class="tag-chip-icon">🏷</span>${tagHtml}<span class="tag-chip-date" title="${t.timestamp}" ${ATTR_DATA_DATE}="${t.timestamp}">${formatted}</span></div></div>`;
66
90
  };
67
91
  export const paginationLinks = (currentPage, totalPages) => {
68
92
  let links = '';
@@ -18,6 +18,7 @@ export interface TimelineTemplateProps {
18
18
  aboutHtml: string;
19
19
  jiraBaseUrl?: string;
20
20
  jiraPrefix?: string;
21
+ repositoryUrl?: string;
21
22
  currentPage?: number;
22
23
  totalPages?: number;
23
24
  }
@@ -60,7 +61,6 @@ export interface TimelineCommitItem {
60
61
  sha: string;
61
62
  message: string;
62
63
  timestamp: string;
63
- relativeTime: string;
64
64
  displayDate: string;
65
65
  monthGroup: string;
66
66
  sortTime: string;
@@ -83,7 +83,6 @@ export interface GitCommit {
83
83
  sha: string;
84
84
  message: string;
85
85
  timestamp: string;
86
- relativeTime: string;
87
86
  }
88
87
  /**
89
88
  * Configuration for the post template
@@ -103,6 +102,7 @@ export interface PostTemplateProps {
103
102
  commits?: GitCommit[];
104
103
  version: string;
105
104
  jiraUrl?: string;
105
+ repositoryUrl?: string;
106
106
  prevEntry: EntryLink | null;
107
107
  nextEntry: EntryLink | null;
108
108
  slug?: string;
@@ -1,6 +1,6 @@
1
1
  import type { TabConfig, TimelineTemplateProps, PostTemplateProps } from './template-types.js';
2
2
  export declare const tabsComponent: (tabs: TabConfig[], defaultActive?: number) => string;
3
- export declare const layout: ({ title, header, content, projectName, basePath, description, bodySlug, buildMeta, }: {
3
+ export declare const layout: ({ title, header, content, projectName, basePath, description, bodySlug, buildMeta, includeRssLink, }: {
4
4
  title: string;
5
5
  header: string;
6
6
  content: string;
@@ -9,6 +9,15 @@ export declare const layout: ({ title, header, content, projectName, basePath, d
9
9
  description?: string;
10
10
  bodySlug?: string;
11
11
  buildMeta?: string;
12
+ includeRssLink?: boolean;
12
13
  }) => string;
13
- export declare const timelineTemplate: (props: TimelineTemplateProps) => string;
14
- export declare const postTemplate: (props: PostTemplateProps) => string;
14
+ export declare const timelineTemplate: (props: TimelineTemplateProps & {
15
+ includeRssLink?: boolean;
16
+ }) => {
17
+ header: string;
18
+ content: string;
19
+ };
20
+ export declare const postTemplate: (props: PostTemplateProps) => {
21
+ header: string;
22
+ content: string;
23
+ };
@@ -1,35 +1,37 @@
1
- import { navLink, renderTagsAndWorkspaces, renderCommits, renderTimelineEntryItem, renderTimelineCommitItem, renderTimelineTagItem, paginationLinks, } from './template-helpers.js';
2
- const html = (strings, ...values) => {
3
- return strings.reduce((acc, str, i) => acc + str + (values[i] ?? ''), '');
4
- };
1
+ import { ATTR_PAGE_SLUG, ATTR_DATA_DATE } from './html-attributes.js';
2
+ import { escapeHtml, validateUrlScheme } from './html-escape.js';
3
+ import { html, navLink, renderTagsAndWorkspaces, renderCommits, renderTimelineEntryItem, renderTimelineCommitItem, renderTimelineTagItem, paginationLinks, } from './template-helpers.js';
5
4
  export const tabsComponent = (tabs, defaultActive = 0) => {
6
5
  const tabsHtml = tabs
7
- .map((tab, index) => `<div class="tab ${index === defaultActive ? 'active' : ''}" role="tab" aria-selected="${index === defaultActive}" aria-controls="${tab.id}" tabindex="${index === defaultActive ? '0' : '-1'}" onclick="showTab(event, '${tab.id}')">${tab.title}</div>`)
6
+ .map((tab, index) => `<div class="tab ${index === defaultActive ? 'active' : ''}" role="tab" aria-selected="${index === defaultActive}" aria-controls="${escapeHtml(tab.id)}" tabindex="${index === defaultActive ? '0' : '-1'}" onclick="showTab(event, '${escapeHtml(tab.id)}')">${escapeHtml(tab.title)}</div>`)
8
7
  .join('');
9
8
  const sectionsHtml = tabs
10
- .map((tab, index) => `<div id="${tab.id}" class="content-section" role="tabpanel" style="${index === defaultActive ? '' : 'display:none;'}">${tab.content}</div>`)
9
+ .map((tab, index) => `<div id="${escapeHtml(tab.id)}" class="content-section" role="tabpanel" style="${index === defaultActive ? '' : 'display:none;'}">${tab.content}</div>`)
11
10
  .join('');
12
11
  return html `<div class="tabs-wrapper">
13
12
  <div class="tabs" id="tabs" role="tablist" aria-label="Navigation tabs">${tabsHtml}</div>
14
13
  ${sectionsHtml}
15
14
  </div>`;
16
15
  };
17
- export const layout = ({ title, header, content, projectName, basePath = './', description, bodySlug, buildMeta, }) => html `<!DOCTYPE html>
16
+ export const layout = ({ title, header, content, projectName, basePath = './', description, bodySlug, buildMeta, includeRssLink = false, }) => html `<!DOCTYPE html>
18
17
  <html lang="en">
19
18
  <head>
20
19
  <meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
21
- <title>${title} | ${projectName}</title>
22
- ${description ? `<meta name="description" content="${description.replace(/"/g, '"').substring(0, 160)}">` : ''}
20
+ <title>${escapeHtml(title)} | ${escapeHtml(projectName)}</title>
21
+ ${description ? `<meta name="description" content="${escapeHtml(description).replace(/"/g, '&quot;').substring(0, 160)}">` : ''}
22
+ ${includeRssLink ? `<link rel="alternate" type="application/rss+xml" title="Subscribe to Project RSS Feed" href="${basePath}rss.xml">` : ''}
23
23
  <link rel="icon" type="image/svg+xml" href="${basePath}favicon.svg">
24
24
  <link rel="stylesheet" href="${basePath}style.css">
25
25
  </head>
26
- <body${bodySlug ? ` data-page-slug="${bodySlug}"` : ''}>
26
+ <body${bodySlug ? ` ${ATTR_PAGE_SLUG}="${bodySlug}"` : ''}>
27
27
  ${header}<main>${content}</main>
28
- <footer>${buildMeta ? `<p class="build-time">${buildMeta}</p>` : ''}</footer>
28
+ <footer>
29
+ ${buildMeta ? `<p class="build-time">${buildMeta}${includeRssLink ? ` • <a href="${basePath}rss.xml">RSS Feed</a>` : ''}</p>` : ''}
30
+ </footer>
29
31
  <script src="${basePath}logbook.js"></script>
30
32
  </body></html>`;
31
33
  export const timelineTemplate = (props) => {
32
- const { projectName, groups, readmeHtml, aboutHtml, jiraBaseUrl, jiraPrefix, currentPage, totalPages } = props;
34
+ const { projectName, groups, readmeHtml, aboutHtml, jiraBaseUrl, jiraPrefix, repositoryUrl, currentPage, totalPages, } = props;
33
35
  const timelineTab = html `<div class="timeline">
34
36
  ${groups
35
37
  .map((g) => html `<section class="month-group">
@@ -39,59 +41,64 @@ export const timelineTemplate = (props) => {
39
41
  if (item.kind === 'entry')
40
42
  return renderTimelineEntryItem(item);
41
43
  if (item.kind === 'tag')
42
- return renderTimelineTagItem(item);
43
- return renderTimelineCommitItem(item, jiraBaseUrl, jiraPrefix);
44
+ return renderTimelineTagItem(item, repositoryUrl);
45
+ return renderTimelineCommitItem(item, jiraBaseUrl, jiraPrefix, repositoryUrl);
44
46
  })
45
47
  .join('')}
46
48
  </section>`)
47
49
  .join('')}
48
50
  ${paginationLinks(currentPage, totalPages)}
49
51
  </div>`;
50
- return html `<header>
51
- <h1><a href="./index.html">${projectName}</a></h1>
52
- <p class="tagline">A brief summary of the recent changes to the project.</p>
53
- </header>
54
- ${tabsComponent([
52
+ const header = html `<header>
53
+ <h1><a href="./index.html">${escapeHtml(projectName)}</a></h1>
54
+ <p class="tagline">A brief summary of the recent changes to the project.</p>
55
+ </header>`;
56
+ const content = tabsComponent([
55
57
  { id: 'timeline', title: 'Timeline', content: timelineTab },
56
58
  { id: 'readme', title: 'Project Readme', content: readmeHtml || '<p>No README available.</p>' },
57
59
  { id: 'about', title: 'About this Logbook', content: aboutHtml || '<p>No information available.</p>' },
58
- ], 0)}`;
60
+ ], 0);
61
+ return { header, content };
59
62
  };
60
63
  export const postTemplate = (props) => {
61
- const { title, displayDate, dateStart, harness, llm, prompter, content, ticketHtml, logHtml, commits, version, jiraUrl, prevEntry, nextEntry, tags, workspaces } = props; // prettier-ignore
62
- const jiraBtn = jiraUrl
63
- ? `<a href="${jiraUrl}" class="jira-link-btn" target="_blank" rel="noopener noreferrer">View in Jira ↗</a>`
64
+ const { title, displayDate, dateStart, harness, llm, prompter, content, ticketHtml, logHtml, commits, version, jiraUrl, repositoryUrl, prevEntry, nextEntry, tags, workspaces } = props; // prettier-ignore
65
+ // Validate jiraUrl scheme to prevent javascript: URLs
66
+ const validatedJiraUrl = jiraUrl ? validateUrlScheme(jiraUrl) : undefined;
67
+ const jiraBtn = validatedJiraUrl
68
+ ? `<a href="${escapeHtml(validatedJiraUrl)}" class="jira-link-btn" target="_blank" rel="noopener noreferrer">View in Jira ↗</a>`
64
69
  : '';
65
- const tabBtn = (id, label, active = false) => `<div class="tab${active ? ' active' : ''}" role="tab" aria-selected="${active}" aria-controls="${id}" tabindex="${active ? '0' : '-1'}" onclick="showTab(event, '${id}')">${label}</div>`;
66
- return html `<header>
67
- <h1>${title}</h1>
68
- <div class="tagline">
69
- <span data-date="${dateStart}">${displayDate}</span> •
70
- ${llm ? `${llm} via ` : ''}${harness}${prompter ? ` / ${prompter}` : ''} • v${version}
71
- </div>
72
- </header>
73
- <article class="tabs-wrapper">
74
- <div class="action-bar">
75
- <a href="../index.html" class="back-link">← Back to Timeline</a>
76
- <div class="action-tabs-wrapper">
77
- <div class="tabs" role="tablist" aria-label="Entry sections">
78
- ${tabBtn('story', 'Story', true)}${tabBtn('spec', 'Original Spec')}${tabBtn('log', 'Technical Log')}
79
- </div>
70
+ const tabBtn = (id, label, active = false) => `<div class="tab${active ? ' active' : ''}" role="tab" aria-selected="${active}" aria-controls="${escapeHtml(id)}" tabindex="${active ? '0' : '-1'}" onclick="showTab(event, '${escapeHtml(id)}')">${escapeHtml(label)}</div>`;
71
+ const header = html `<header>
72
+ <h1>${escapeHtml(title)}</h1>
73
+ <div class="tagline">
74
+ <span ${ATTR_DATA_DATE}="${dateStart}">${displayDate}</span> •
75
+ ${llm ? `${escapeHtml(llm)} via ` : ''}${escapeHtml(harness)}${prompter ? ` / ${escapeHtml(prompter)}` : ''} •
76
+ v${version}
77
+ </div>
78
+ </header>`;
79
+ const mainContent = html `<article class="tabs-wrapper">
80
+ <div class="action-bar">
81
+ <a href="../index.html" class="back-link">← Back to Timeline</a>
82
+ <div class="action-tabs-wrapper">
83
+ <div class="tabs" role="tablist" aria-label="Entry sections">
84
+ ${tabBtn('story', 'Story', true)}${tabBtn('spec', 'Original Spec')}${tabBtn('log', 'Technical Log')}
80
85
  </div>
81
- ${jiraBtn ? `${jiraBtn}` : ''}
82
86
  </div>
83
- <div class="content-sections">
84
- <div id="story" class="content-section" role="tabpanel">
85
- ${renderTagsAndWorkspaces(tags, workspaces)}${content}
86
- </div>
87
- <div id="spec" class="content-section" role="tabpanel" style="display:none;">${ticketHtml}</div>
88
- <div id="log" class="content-section" role="tabpanel" style="display:none;">
89
- ${logHtml}${renderCommits(commits)}
90
- </div>
87
+ ${jiraBtn ? `${jiraBtn}` : ''}
88
+ </div>
89
+ <div class="content-sections">
90
+ <div id="story" class="content-section" role="tabpanel">
91
+ ${renderTagsAndWorkspaces(tags, workspaces)}${content}
92
+ </div>
93
+ <div id="spec" class="content-section" role="tabpanel" style="display:none;">${ticketHtml}</div>
94
+ <div id="log" class="content-section" role="tabpanel" style="display:none;">
95
+ ${logHtml}${renderCommits(commits, repositoryUrl)}
91
96
  </div>
92
- <nav class="post-nav">
93
- <div class="post-nav-prev">${prevEntry ? navLink(prevEntry, 'prev') : '<span></span>'}</div>
94
- <div class="post-nav-next">${nextEntry ? navLink(nextEntry, 'next') : '<span></span>'}</div>
95
- </nav>
96
- </article>`;
97
+ </div>
98
+ <nav class="post-nav">
99
+ <div class="post-nav-prev">${prevEntry ? navLink(prevEntry, 'prev') : '<span></span>'}</div>
100
+ <div class="post-nav-next">${nextEntry ? navLink(nextEntry, 'next') : '<span></span>'}</div>
101
+ </nav>
102
+ </article>`;
103
+ return { header, content: mainContent };
97
104
  };