project-logbook 0.3.3 → 0.3.4

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 (65) hide show
  1. package/dist/commands/build.js +32 -75
  2. package/dist/commands/init.js +11 -11
  3. package/dist/commands/lint.js +10 -11
  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 +122 -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 +28 -50
  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 +16 -0
  19. package/dist/lib/config.js +20 -0
  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 +11 -1
  25. package/dist/lib/git-helpers.js +28 -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 +18 -0
  35. package/dist/lib/markdown-processors.js +42 -0
  36. package/dist/lib/package-version.d.ts +5 -0
  37. package/dist/lib/package-version.js +16 -0
  38. package/dist/lib/styles.js +5 -2
  39. package/dist/lib/template-helpers.d.ts +1 -0
  40. package/dist/lib/template-helpers.js +35 -24
  41. package/dist/lib/template-types.d.ts +0 -2
  42. package/dist/lib/templates.d.ts +8 -2
  43. package/dist/lib/templates.js +50 -46
  44. package/dist/lib/theme.d.ts +37 -0
  45. package/dist/lib/theme.js +50 -0
  46. package/dist/lib/url-helpers.d.ts +13 -0
  47. package/dist/lib/url-helpers.js +27 -0
  48. package/dist/linters/diff-to-narrative.d.ts +6 -0
  49. package/dist/linters/diff-to-narrative.js +114 -0
  50. package/dist/linters/index.js +2 -0
  51. package/dist/templates/CONTRIBUTING.md +12 -3
  52. package/dist/templates/index.md +6 -6
  53. package/dist/templates/logbook-client.js +42 -16
  54. package/dist/templates/styles.css +5 -0
  55. package/dist/utils/date.d.ts +23 -1
  56. package/dist/utils/date.js +56 -15
  57. package/dist/utils/frontmatter.d.ts +26 -0
  58. package/dist/utils/frontmatter.js +37 -0
  59. package/dist/utils/fs.d.ts +13 -0
  60. package/dist/utils/fs.js +23 -0
  61. package/package.json +2 -2
  62. package/src/templates/CONTRIBUTING.md +12 -3
  63. package/src/templates/index.md +6 -6
  64. package/src/templates/logbook-client.js +42 -16
  65. package/src/templates/styles.css +5 -0
@@ -0,0 +1,55 @@
1
+ import { join } from 'node:path';
2
+ /**
3
+ * Encapsulates knowledge of logbook entry folder structure.
4
+ * Provides consistent path construction for entry-related files.
5
+ */
6
+ /**
7
+ * Get the base directory path for a logbook entry.
8
+ * @param logbookDir - The root logbook directory (e.g., "/project/logbook")
9
+ * @param entrySlug - The entry's slug (e.g., "LB-43-my-feature")
10
+ * @returns Path to the entry folder (e.g., "/project/logbook/LB-43-my-feature")
11
+ */
12
+ export function getEntryPath(logbookDir, entrySlug) {
13
+ return join(logbookDir, entrySlug);
14
+ }
15
+ /**
16
+ * Get the path to an entry's index.md file.
17
+ * @param logbookDir - The root logbook directory
18
+ * @param entrySlug - The entry's slug
19
+ * @returns Path to index.md
20
+ */
21
+ function getEntryIndexPath(logbookDir, entrySlug) {
22
+ return join(getEntryPath(logbookDir, entrySlug), 'index.md');
23
+ }
24
+ /**
25
+ * Get the path to an entry's ticket.md file.
26
+ * @param logbookDir - The root logbook directory
27
+ * @param entrySlug - The entry's slug
28
+ * @returns Path to ticket.md
29
+ */
30
+ function getEntryTicketPath(logbookDir, entrySlug) {
31
+ return join(getEntryPath(logbookDir, entrySlug), 'ticket.md');
32
+ }
33
+ /**
34
+ * Get the path to an entry's log.md file.
35
+ * @param logbookDir - The root logbook directory
36
+ * @param entrySlug - The entry's slug
37
+ * @returns Path to log.md
38
+ */
39
+ function getEntryLogPath(logbookDir, entrySlug) {
40
+ return join(getEntryPath(logbookDir, entrySlug), 'log.md');
41
+ }
42
+ /**
43
+ * Get all standard entry file paths at once.
44
+ * Useful when you need to access index, ticket, and log files.
45
+ * @param logbookDir - The root logbook directory
46
+ * @param entrySlug - The entry's slug
47
+ * @returns Object with paths to index.md, ticket.md, and log.md
48
+ */
49
+ export function getEntryFilePaths(logbookDir, entrySlug) {
50
+ return {
51
+ index: getEntryIndexPath(logbookDir, entrySlug),
52
+ ticket: getEntryTicketPath(logbookDir, entrySlug),
53
+ log: getEntryLogPath(logbookDir, entrySlug),
54
+ };
55
+ }
@@ -5,8 +5,10 @@ import type { GitCommit } from './template-types.js';
5
5
  *
6
6
  * Uses indexOf/lastIndexOf to delimit fields so commit messages containing `|`
7
7
  * are preserved correctly instead of being silently truncated.
8
+ *
9
+ * Note: Relative time calculation is done client-side via JavaScript, not here.
8
10
  */
9
- export declare function parseGitLogOutput(raw: string, now?: Date): GitCommit[];
11
+ export declare function parseGitLogOutput(raw: string): GitCommit[];
10
12
  /**
11
13
  * Fetch git commits that touched `dir`, up to `maxCount`.
12
14
  * Returns an empty array if git is unavailable or the directory is not a repo.
@@ -25,3 +27,11 @@ export declare function getGitTags(): Promise<{
25
27
  * Kept synchronous intentionally; simple-git is used for the heavier async build operations.
26
28
  */
27
29
  export declare function getCurrentBranch(cwd?: string): string | undefined;
30
+ /**
31
+ * Calculate the changed LOC (insertions + deletions) for the active branch or main working tree.
32
+ */
33
+ export declare function getChangedLOC(logbookDir: string, cwd?: string): Promise<{
34
+ total: number;
35
+ insertions: number;
36
+ deletions: number;
37
+ }>;
@@ -9,8 +9,10 @@ function getGit() {
9
9
  *
10
10
  * Uses indexOf/lastIndexOf to delimit fields so commit messages containing `|`
11
11
  * are preserved correctly instead of being silently truncated.
12
+ *
13
+ * Note: Relative time calculation is done client-side via JavaScript, not here.
12
14
  */
13
- export function parseGitLogOutput(raw, now = new Date()) {
15
+ export function parseGitLogOutput(raw) {
14
16
  if (!raw.trim())
15
17
  return [];
16
18
  return raw
@@ -26,34 +28,10 @@ export function parseGitLogOutput(raw, now = new Date()) {
26
28
  const timestamp = line.slice(lastPipe + 1);
27
29
  if (!sha || !message || !timestamp)
28
30
  return null;
29
- const relativeTime = formatRelativeTime(timestamp, now);
30
- return { sha: sha.slice(0, 7), message, timestamp, relativeTime };
31
+ return { sha: sha.slice(0, 7), message, timestamp };
31
32
  })
32
33
  .filter((c) => c !== null);
33
34
  }
34
- function formatRelativeTime(isoTimestamp, now) {
35
- const d = new Date(isoTimestamp);
36
- if (isNaN(d.getTime()))
37
- return isoTimestamp;
38
- const diffMs = now.getTime() - d.getTime();
39
- const diffSec = Math.floor(diffMs / 1000);
40
- if (diffSec < 60)
41
- return 'just now';
42
- const diffMin = Math.floor(diffSec / 60);
43
- if (diffMin < 60)
44
- return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`;
45
- const diffHour = Math.floor(diffMin / 60);
46
- if (diffHour < 24)
47
- return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`;
48
- const diffDay = Math.floor(diffHour / 24);
49
- if (diffDay < 30)
50
- return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
51
- const diffMonth = Math.floor(diffDay / 30);
52
- if (diffMonth < 12)
53
- return `${diffMonth} month${diffMonth === 1 ? '' : 's'} ago`;
54
- const diffYear = Math.floor(diffMonth / 12);
55
- return `${diffYear} year${diffYear === 1 ? '' : 's'} ago`;
56
- }
57
35
  /**
58
36
  * Fetch git commits that touched `dir`, up to `maxCount`.
59
37
  * Returns an empty array if git is unavailable or the directory is not a repo.
@@ -114,3 +92,27 @@ export function getCurrentBranch(cwd = process.cwd()) {
114
92
  return undefined;
115
93
  }
116
94
  }
95
+ /**
96
+ * Calculate the changed LOC (insertions + deletions) for the active branch or main working tree.
97
+ */
98
+ export async function getChangedLOC(logbookDir, cwd = process.cwd()) {
99
+ try {
100
+ const branchName = getCurrentBranch(cwd) || 'main';
101
+ const git = simpleGit(cwd);
102
+ let summary;
103
+ if (branchName === 'main') {
104
+ summary = await git.diffSummary(['HEAD', '--', '.', `:!${logbookDir}`]);
105
+ }
106
+ else {
107
+ summary = await git.diffSummary(['main...HEAD', '--', '.', `:!${logbookDir}`]);
108
+ }
109
+ return {
110
+ total: summary.insertions + summary.deletions,
111
+ insertions: summary.insertions,
112
+ deletions: summary.deletions,
113
+ };
114
+ }
115
+ catch {
116
+ return { total: 0, insertions: 0, deletions: 0 };
117
+ }
118
+ }
@@ -0,0 +1,10 @@
1
+ import type { Root, Element } from 'hast';
2
+ /**
3
+ * Generic HAST tree visitor that traverses depth-first and applies a callback
4
+ * to elements matching the specified tagName(s).
5
+ *
6
+ * @param node - Root or Element node to traverse
7
+ * @param tagNames - Single tag name or array of tag names to match
8
+ * @param visitor - Callback applied to each matching element
9
+ */
10
+ export declare function visitHastElements(node: Root | Element, tagNames: string | string[], visitor: (node: Element) => void): void;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Generic HAST tree visitor that traverses depth-first and applies a callback
3
+ * to elements matching the specified tagName(s).
4
+ *
5
+ * @param node - Root or Element node to traverse
6
+ * @param tagNames - Single tag name or array of tag names to match
7
+ * @param visitor - Callback applied to each matching element
8
+ */
9
+ export function visitHastElements(node, tagNames, visitor) {
10
+ const tagNamesSet = new Set(Array.isArray(tagNames) ? tagNames : [tagNames]);
11
+ function traverse(n) {
12
+ for (const child of n.children) {
13
+ if (child.type === 'element') {
14
+ if (tagNamesSet.has(child.tagName)) {
15
+ visitor(child);
16
+ }
17
+ traverse(child);
18
+ }
19
+ }
20
+ }
21
+ traverse(node);
22
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * HTML attribute name constants.
3
+ * Centralized definitions for data attributes and class names used in templates
4
+ * to maintain consistency between template generation and JavaScript consumers.
5
+ */
6
+ /** data-page-slug: Identifies the current page/entry slug in the document body */
7
+ export declare const ATTR_PAGE_SLUG = "data-page-slug";
8
+ /** data-entry-slug: Identifies a timeline entry's slug */
9
+ export declare const ATTR_ENTRY_SLUG = "data-entry-slug";
10
+ /** data-date: Stores a timestamp for client-side date formatting */
11
+ export declare const ATTR_DATA_DATE = "data-date";
12
+ /** Class name for commit SHA display */
13
+ export declare const CLASS_COMMIT_SHA = "commit-sha";
14
+ /** Class name for commit timestamp display */
15
+ export declare const CLASS_COMMIT_TIME = "commit-time";
16
+ /** Class name for commit message display */
17
+ export declare const CLASS_COMMIT_MESSAGE = "commit-message";
@@ -0,0 +1,17 @@
1
+ /**
2
+ * HTML attribute name constants.
3
+ * Centralized definitions for data attributes and class names used in templates
4
+ * to maintain consistency between template generation and JavaScript consumers.
5
+ */
6
+ /** data-page-slug: Identifies the current page/entry slug in the document body */
7
+ export const ATTR_PAGE_SLUG = 'data-page-slug';
8
+ /** data-entry-slug: Identifies a timeline entry's slug */
9
+ export const ATTR_ENTRY_SLUG = 'data-entry-slug';
10
+ /** data-date: Stores a timestamp for client-side date formatting */
11
+ export const ATTR_DATA_DATE = 'data-date';
12
+ /** Class name for commit SHA display */
13
+ export const CLASS_COMMIT_SHA = 'commit-sha';
14
+ /** Class name for commit timestamp display */
15
+ export const CLASS_COMMIT_TIME = 'commit-time';
16
+ /** Class name for commit message display */
17
+ export const CLASS_COMMIT_MESSAGE = 'commit-message';
@@ -0,0 +1,16 @@
1
+ /**
2
+ * HTML entity escaping utility.
3
+ * Escapes characters that have special meaning in HTML to prevent XSS attacks.
4
+ */
5
+ export declare function escapeHtml(str: string): string;
6
+ /**
7
+ * Validates a hex color string.
8
+ * Accepts 3-digit (#abc), 6-digit (#abcdef), or 8-digit (#abcdef00) hex colors.
9
+ * Returns true if valid, false otherwise.
10
+ */
11
+ export declare function isValidHexColor(color: string): boolean;
12
+ /**
13
+ * Validates that a URL has a safe scheme (http or https).
14
+ * Returns the URL if valid, undefined if invalid.
15
+ */
16
+ export declare function validateUrlScheme(url: string): string | undefined;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * HTML entity escaping utility.
3
+ * Escapes characters that have special meaning in HTML to prevent XSS attacks.
4
+ */
5
+ export function escapeHtml(str) {
6
+ if (!str)
7
+ return '';
8
+ return str
9
+ .replace(/&/g, '&amp;')
10
+ .replace(/</g, '&lt;')
11
+ .replace(/>/g, '&gt;')
12
+ .replace(/"/g, '&quot;')
13
+ .replace(/'/g, '&#39;');
14
+ }
15
+ /**
16
+ * Validates a hex color string.
17
+ * Accepts 3-digit (#abc), 6-digit (#abcdef), or 8-digit (#abcdef00) hex colors.
18
+ * Returns true if valid, false otherwise.
19
+ */
20
+ export function isValidHexColor(color) {
21
+ return /^#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?(?:[0-9a-fA-F]{2})?$/.test(color);
22
+ }
23
+ /**
24
+ * Validates that a URL has a safe scheme (http or https).
25
+ * Returns the URL if valid, undefined if invalid.
26
+ */
27
+ export function validateUrlScheme(url) {
28
+ try {
29
+ const parsed = new URL(url);
30
+ if (parsed.protocol === 'https:' || parsed.protocol === 'http:') {
31
+ return url;
32
+ }
33
+ return undefined;
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ }
@@ -1,13 +1,9 @@
1
1
  import fs from 'fs-extra';
2
- import { join, dirname, relative } from 'node:path';
3
- import { unified } from 'unified';
4
- import remarkParse from 'remark-parse';
5
- import remarkGfm from 'remark-gfm';
6
- import remarkRehype from 'remark-rehype';
7
- import rehypeSlug from 'rehype-slug';
8
- import rehypeFormat from 'rehype-format';
9
- import rehypeStringify from 'rehype-stringify';
10
- import chalk from 'chalk';
2
+ import { join, dirname, relative, resolve } from 'node:path';
3
+ import { warning } from './theme.js';
4
+ import { visitHastElements } from './hast-helpers.js';
5
+ import { isExternalUrl } from './url-helpers.js';
6
+ import { createImageCollectionProcessor } from './markdown-processors.js';
11
7
  /** Regex to match image links in markdown: ![alt](path) */
12
8
  const imageLinkRegex = /!\[.*?\]\(([^)]+\.(?:png|jpe?g|gif|svg|webp|bmp|ico))\)/gi;
13
9
  /**
@@ -34,12 +30,12 @@ class ImageProcessor {
34
30
  /** Rehype plugin: collect image paths from markdown */
35
31
  rehypeCollectImages = () => {
36
32
  return (tree) => {
37
- visitImages(tree, (node) => {
33
+ visitHastElements(tree, 'img', (node) => {
38
34
  const src = node.properties?.src;
39
35
  if (typeof src !== 'string')
40
36
  return;
41
37
  // Skip external URLs and data URIs
42
- if (/^[a-z][a-z\d+\-.]*:/i.test(src))
38
+ if (isExternalUrl(src))
43
39
  return;
44
40
  if (src.startsWith('data:'))
45
41
  return;
@@ -49,24 +45,15 @@ class ImageProcessor {
49
45
  };
50
46
  }
51
47
  const imageProcessor = new ImageProcessor();
52
- function visitImages(node, visitor) {
53
- for (const child of node.children) {
54
- if (child.type === 'element') {
55
- if (child.tagName === 'img')
56
- visitor(child);
57
- visitImages(child, visitor);
58
- }
59
- }
60
- }
61
48
  /** Rehype plugin: rewrite relative image paths to use /images/ subfolder */
62
49
  export const rehypeRewriteImagePaths = () => {
63
50
  return (tree) => {
64
- visitImages(tree, (node) => {
51
+ visitHastElements(tree, 'img', (node) => {
65
52
  const src = node.properties?.src;
66
53
  if (typeof src !== 'string')
67
54
  return;
68
55
  // Skip external URLs and data URIs
69
- if (/^[a-z][a-z\d+\-.]*:/i.test(src))
56
+ if (isExternalUrl(src))
70
57
  return;
71
58
  if (src.startsWith('data:'))
72
59
  return;
@@ -87,15 +74,7 @@ export const rehypeRewriteImagePaths = () => {
87
74
  */
88
75
  export async function mdToHtmlWithImages(md) {
89
76
  imageProcessor.reset();
90
- const processorWithImages = unified()
91
- .use(remarkParse)
92
- .use(remarkGfm)
93
- .use(remarkRehype)
94
- .use(imageProcessor.rehypeCollectImages)
95
- .use(rehypeRewriteImagePaths)
96
- .use(rehypeSlug)
97
- .use(rehypeFormat)
98
- .use(rehypeStringify);
77
+ const processorWithImages = createImageCollectionProcessor(imageProcessor.rehypeCollectImages, rehypeRewriteImagePaths);
99
78
  const result = await processorWithImages.process(md);
100
79
  return { html: result.toString(), imagePaths: imageProcessor.getImagePaths() };
101
80
  }
@@ -108,6 +87,7 @@ export async function mdToHtmlWithImages(md) {
108
87
  export async function copyImages(sourceDir, outputDir, imagePaths) {
109
88
  await fs.mkdirp(outputDir);
110
89
  const processedPaths = new Set();
90
+ const projectRoot = resolve(process.cwd());
111
91
  for (const imagePath of imagePaths) {
112
92
  // Skip if already processed
113
93
  if (processedPaths.has(imagePath))
@@ -119,7 +99,13 @@ export async function copyImages(sourceDir, outputDir, imagePaths) {
119
99
  if (imagePath.startsWith('/')) {
120
100
  // Absolute path like /static/screenshot.jpg -> copy from project root to public/static/
121
101
  const relativeToProjectRoot = imagePath.substring(1); // Remove leading /
122
- sourcePath = join(process.cwd(), relativeToProjectRoot);
102
+ sourcePath = join(projectRoot, relativeToProjectRoot);
103
+ // Path traversal protection: ensure resolved path stays within project root
104
+ const resolvedSource = resolve(sourcePath);
105
+ if (!resolvedSource.startsWith(projectRoot + '/') && resolvedSource !== projectRoot) {
106
+ console.warn(warning(` Path traversal blocked: ${imagePath}`));
107
+ continue;
108
+ }
123
109
  destRelPath = relativeToProjectRoot;
124
110
  }
125
111
  else {
@@ -130,12 +116,19 @@ export async function copyImages(sourceDir, outputDir, imagePaths) {
130
116
  }
131
117
  const fullPath = join(sourceDir, resolvedPath);
132
118
  sourcePath = fullPath;
119
+ // Path traversal protection: ensure resolved path stays within source directory
120
+ const resolvedSource = resolve(sourcePath);
121
+ const resolvedSourceDir = resolve(sourceDir);
122
+ if (!resolvedSource.startsWith(resolvedSourceDir + '/') && resolvedSource !== resolvedSourceDir) {
123
+ console.warn(warning(` Path traversal blocked: ${imagePath}`));
124
+ continue;
125
+ }
133
126
  // Get the relative path from source dir to maintain folder structure
134
127
  destRelPath = relative(sourceDir, fullPath);
135
128
  }
136
129
  // Check if image exists
137
130
  if (!(await fs.pathExists(sourcePath))) {
138
- console.warn(chalk.yellow(` Image not found: ${sourcePath}`));
131
+ console.warn(warning(` Image not found: ${sourcePath}`));
139
132
  continue;
140
133
  }
141
134
  const destPath = join(outputDir, destRelPath);
@@ -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,18 @@
1
+ import { unified } from 'unified';
2
+ import type { Plugin } from 'unified';
3
+ import type { Root } from 'hast';
4
+ /**
5
+ * General markdown processor for project files (README.md, etc.)
6
+ * Used for files that don't need image path rewriting
7
+ */
8
+ export declare function createGeneralMarkdownProcessor(rehypeRewriteMdLinks: Plugin<[], Root>): ReturnType<typeof unified.prototype.use>;
9
+ /**
10
+ * Entry markdown processor with image path rewriting
11
+ * Used for logbook entry content (index.md, ticket.md, log.md)
12
+ */
13
+ export declare function createEntryMarkdownProcessor(rehypeRewriteMdLinks: Plugin<[], Root>, rehypeRewriteImagePaths: Plugin<[], Root>): ReturnType<typeof unified.prototype.use>;
14
+ /**
15
+ * Image collection processor
16
+ * Collects all image paths during markdown-to-html conversion
17
+ */
18
+ export declare function createImageCollectionProcessor(imageCollectionPlugin: Plugin<[], Root>, rehypeRewriteImagePaths: Plugin<[], Root>): ReturnType<typeof unified.prototype.use>;
@@ -0,0 +1,42 @@
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
+ /**
9
+ * Create a markdown processor with custom rehype plugins
10
+ *
11
+ * @param plugins - Array of rehype plugins to inject before formatting
12
+ * @returns A unified processor
13
+ */
14
+ function createMarkdownProcessor(plugins = []) {
15
+ const processor = unified().use(remarkParse).use(remarkGfm).use(remarkRehype);
16
+ // Apply custom plugins before formatting
17
+ for (const plugin of plugins) {
18
+ processor.use(plugin);
19
+ }
20
+ return processor.use(rehypeSlug).use(rehypeFormat).use(rehypeStringify);
21
+ }
22
+ /**
23
+ * General markdown processor for project files (README.md, etc.)
24
+ * Used for files that don't need image path rewriting
25
+ */
26
+ export function createGeneralMarkdownProcessor(rehypeRewriteMdLinks) {
27
+ return createMarkdownProcessor([rehypeRewriteMdLinks]);
28
+ }
29
+ /**
30
+ * Entry markdown processor with image path rewriting
31
+ * Used for logbook entry content (index.md, ticket.md, log.md)
32
+ */
33
+ export function createEntryMarkdownProcessor(rehypeRewriteMdLinks, rehypeRewriteImagePaths) {
34
+ return createMarkdownProcessor([rehypeRewriteMdLinks, rehypeRewriteImagePaths]);
35
+ }
36
+ /**
37
+ * Image collection processor
38
+ * Collects all image paths during markdown-to-html conversion
39
+ */
40
+ export function createImageCollectionProcessor(imageCollectionPlugin, rehypeRewriteImagePaths) {
41
+ return createMarkdownProcessor([imageCollectionPlugin, rehypeRewriteImagePaths]);
42
+ }
@@ -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
+ }
@@ -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,4 +1,5 @@
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
5
  export declare const renderCommits: (commits: GitCommit[] | undefined) => string;
@@ -1,20 +1,25 @@
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 { ATTR_ENTRY_SLUG, ATTR_DATA_DATE, CLASS_COMMIT_SHA, CLASS_COMMIT_TIME, CLASS_COMMIT_MESSAGE, } from './html-attributes.js';
5
+ export const html = (strings, ...values) => {
3
6
  return strings.reduce((acc, str, i) => acc + str + (values[i] ?? ''), '');
4
7
  };
5
8
  export const navLink = (entry, dir) => {
6
9
  const label = dir === 'prev' ? '← Previous' : 'Next →';
7
- const t = `${entry.ticket ? `${entry.ticket}: ` : ''}${entry.title}`;
10
+ const ticket = entry.ticket ? escapeHtml(entry.ticket) : '';
11
+ const title = escapeHtml(entry.title);
12
+ const t = `${ticket ? `${ticket}: ` : ''}${title}`;
8
13
  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
14
  };
10
15
  const renderWorkspaces = (workspaces) => {
11
16
  if (!workspaces || workspaces.length === 0)
12
17
  return '';
13
- return workspaces.map((ws) => `<span class="tag-badge tag-workspace">${ws}</span>`).join('');
18
+ return workspaces.map((ws) => `<span class="tag-badge tag-workspace">${escapeHtml(ws)}</span>`).join('');
14
19
  };
15
20
  export const renderTagsAndWorkspaces = (tags, workspaces) => {
16
21
  const tagsHtml = tags && tags.length > 0
17
- ? tags.map((tag) => `<span class="tag-badge tag-${tag.replace('#', '')}">${tag}</span>`).join('')
22
+ ? tags.map((tag) => `<span class="tag-badge tag-${tag.replace('#', '')}">${escapeHtml(tag)}</span>`).join('')
18
23
  : '';
19
24
  const wsHtml = renderWorkspaces(workspaces);
20
25
  if (!tagsHtml && !wsHtml)
@@ -26,43 +31,49 @@ export const renderCommits = (commits) => {
26
31
  if (!commits || commits.length === 0)
27
32
  return '';
28
33
  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>`)
34
+ .map((c) => {
35
+ const formatted = formatAbsoluteDate(c.timestamp);
36
+ const sha = escapeHtml(c.sha);
37
+ const message = escapeHtml(c.message);
38
+ return `<div class="commit-item"><span class="${CLASS_COMMIT_SHA}">${sha}</span><span class="${CLASS_COMMIT_MESSAGE}">${message}</span><span class="${CLASS_COMMIT_TIME}" title="${c.timestamp}" ${ATTR_DATA_DATE}="${c.timestamp}">${formatted}</span></div>`;
39
+ })
30
40
  .join('');
31
41
  return `<div class="commit-list"><h3 class="commit-list-heading">Git Commits</h3>${rows}</div>`;
32
42
  };
33
- export const renderTimelineEntryItem = (e) => html `<div class="timeline-item" data-entry-slug="${e.slug}">
43
+ export const renderTimelineEntryItem = (e) => {
44
+ const ticket = e.ticket ? escapeHtml(e.ticket) : '';
45
+ const title = e.title ? escapeHtml(e.title) : '';
46
+ const llm = e.llm ? escapeHtml(e.llm) : '';
47
+ const harness = e.harness ? escapeHtml(e.harness) : '';
48
+ const prompter = e.prompter ? escapeHtml(e.prompter) : '';
49
+ const summary = e.summary ? escapeHtml(e.summary) : '';
50
+ return html `<div class="timeline-item" ${ATTR_ENTRY_SLUG}="${e.slug}">
34
51
  <a class="timeline-card" href="./${e.slug}/index.html"
35
52
  ><div class="item-content">
36
53
  <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}` : ''}
54
+ <span ${ATTR_DATA_DATE}="${e.dateStart}">${e.displayDate}</span> •
55
+ ${llm ? ` ${llm} via ` : ''}${harness}${prompter ? ` / ${prompter}` : ''}
39
56
  </div>
40
57
  <h3 class="item-title">
41
- <span class="new-badge" style="display:none;">NEW</span>${e.ticket ? `${e.ticket}: ` : ''}${e.title}
58
+ <span class="new-badge" style="display:none;">NEW</span>${ticket ? `${ticket}: ` : ''}${title}
42
59
  </h3>
43
- <div class="item-summary">${e.summary}</div>
60
+ <div class="item-summary">${summary}</div>
44
61
  ${renderTagsAndWorkspaces(e.tags, e.workspaces)}
45
62
  </div>
46
63
  <span class="item-arrow">›</span></a
47
64
  >
48
65
  </div>`;
66
+ };
49
67
  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>`;
68
+ const message = jiraBaseUrl && jiraPrefix ? linkJiraIds(c.message, jiraBaseUrl, jiraPrefix) : escapeHtml(c.message);
69
+ const formatted = formatAbsoluteDate(c.timestamp);
70
+ const sha = escapeHtml(c.sha);
71
+ return `<div class="timeline-item timeline-item--commit"><div class="commit-chip"><span class="${CLASS_COMMIT_SHA}">${sha}</span><span class="${CLASS_COMMIT_MESSAGE}">${message}</span><span class="${CLASS_COMMIT_TIME}" title="${c.timestamp}" ${ATTR_DATA_DATE}="${c.timestamp}">${formatted}</span></div></div>`;
52
72
  };
53
73
  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>`;
74
+ const formatted = formatAbsoluteDate(t.timestamp);
75
+ const name = escapeHtml(t.name);
76
+ return `<div class="timeline-item timeline-item--tag"><div class="tag-chip"><span class="tag-chip-icon">🏷</span><span class="tag-chip-name">${name}</span><span class="tag-chip-date" title="${t.timestamp}" ${ATTR_DATA_DATE}="${t.timestamp}">${formatted}</span></div></div>`;
66
77
  };
67
78
  export const paginationLinks = (currentPage, totalPages) => {
68
79
  let links = '';