project-logbook 0.3.2 → 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 (68) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/build.js +33 -76
  3. package/dist/commands/init.js +11 -11
  4. package/dist/commands/lint.js +10 -11
  5. package/dist/commands/list.js +18 -18
  6. package/dist/commands/log.js +5 -5
  7. package/dist/commands/new.js +19 -20
  8. package/dist/commands/preview.js +9 -8
  9. package/dist/commands/release.js +6 -6
  10. package/dist/commands/start.js +26 -21
  11. package/dist/commands/status.d.ts +1 -0
  12. package/dist/commands/status.js +122 -0
  13. package/dist/commands/steer.js +9 -9
  14. package/dist/commands/upgrade.js +5 -5
  15. package/dist/index.js +9 -8
  16. package/dist/lib/build-helpers.d.ts +7 -0
  17. package/dist/lib/build-helpers.js +46 -46
  18. package/dist/lib/build-steps.d.ts +20 -0
  19. package/dist/lib/build-steps.js +57 -0
  20. package/dist/lib/config.d.ts +16 -0
  21. package/dist/lib/config.js +20 -0
  22. package/dist/lib/entry-id.d.ts +22 -0
  23. package/dist/lib/entry-id.js +26 -0
  24. package/dist/lib/entry-paths.d.ts +23 -0
  25. package/dist/lib/entry-paths.js +55 -0
  26. package/dist/lib/git-helpers.d.ts +11 -1
  27. package/dist/lib/git-helpers.js +28 -26
  28. package/dist/lib/hast-helpers.d.ts +10 -0
  29. package/dist/lib/hast-helpers.js +22 -0
  30. package/dist/lib/html-attributes.d.ts +17 -0
  31. package/dist/lib/html-attributes.js +17 -0
  32. package/dist/lib/html-escape.d.ts +16 -0
  33. package/dist/lib/html-escape.js +38 -0
  34. package/dist/lib/image-helpers.d.ts +4 -0
  35. package/dist/lib/image-helpers.js +47 -30
  36. package/dist/lib/lint-runner.js +5 -5
  37. package/dist/lib/markdown-processors.d.ts +18 -0
  38. package/dist/lib/markdown-processors.js +42 -0
  39. package/dist/lib/package-version.d.ts +5 -0
  40. package/dist/lib/package-version.js +16 -0
  41. package/dist/lib/styles.js +5 -2
  42. package/dist/lib/template-helpers.d.ts +1 -0
  43. package/dist/lib/template-helpers.js +35 -24
  44. package/dist/lib/template-types.d.ts +0 -2
  45. package/dist/lib/templates.d.ts +8 -2
  46. package/dist/lib/templates.js +50 -46
  47. package/dist/lib/theme.d.ts +37 -0
  48. package/dist/lib/theme.js +50 -0
  49. package/dist/lib/url-helpers.d.ts +13 -0
  50. package/dist/lib/url-helpers.js +27 -0
  51. package/dist/linters/diff-to-narrative.d.ts +6 -0
  52. package/dist/linters/diff-to-narrative.js +114 -0
  53. package/dist/linters/index.js +2 -0
  54. package/dist/templates/CONTRIBUTING.md +12 -3
  55. package/dist/templates/index.md +6 -6
  56. package/dist/templates/logbook-client.js +42 -16
  57. package/dist/templates/styles.css +5 -0
  58. package/dist/utils/date.d.ts +23 -1
  59. package/dist/utils/date.js +56 -15
  60. package/dist/utils/frontmatter.d.ts +26 -0
  61. package/dist/utils/frontmatter.js +37 -0
  62. package/dist/utils/fs.d.ts +13 -0
  63. package/dist/utils/fs.js +23 -0
  64. package/package.json +3 -2
  65. package/src/templates/CONTRIBUTING.md +12 -3
  66. package/src/templates/index.md +6 -6
  67. package/src/templates/logbook-client.js +42 -16
  68. package/src/templates/styles.css +5 -0
@@ -1,10 +1,11 @@
1
1
  import fs from 'fs-extra';
2
2
  import { join } from 'node:path';
3
3
  import { execSync } from 'node:child_process';
4
- import chalk from 'chalk';
4
+ import { error as errorStyle, warning as warningStyle, neutral, header, successMessage } from '../lib/theme.js';
5
5
  import matter from 'gray-matter';
6
- import { getConfig } from '../lib/config.js';
6
+ import { getConfig, getLogbookDirPath } from '../lib/config.js';
7
7
  import { getActiveEntry } from '../lib/session.js';
8
+ import { getEntryPath } from '../lib/entry-paths.js';
8
9
  import { LOCKFILE_NAME } from '../lib/lockfile.js';
9
10
  function getGitUsername() {
10
11
  try {
@@ -22,31 +23,31 @@ export async function start(id) {
22
23
  // 1. Enforce single active entry
23
24
  if (await fs.pathExists(lockfilePath)) {
24
25
  const existing = await fs.readJson(lockfilePath);
25
- console.error(chalk.red(`Error: Entry '${existing.slug}' is already active.`));
26
- console.error(chalk.red(`Run 'logbook release' before starting a new entry.`));
26
+ console.error(errorStyle(`Error: Entry '${existing.slug}' is already active.`));
27
+ console.error(errorStyle(`Run 'logbook release' before starting a new entry.`));
27
28
  process.exit(1);
28
29
  }
29
30
  // 2. Resolve folder by ID prefix (e.g. "LB-5" → "LB-5-jira-support")
30
31
  const config = getConfig();
31
32
  // If a bare number is given (e.g. "19"), auto-prepend the configured jiraPrefix
32
33
  const resolvedId = /^\d+$/.test(id) && config.jiraPrefix ? `${config.jiraPrefix}-${id}` : id;
33
- const logbookDir = join(cwd, config.logbookDir);
34
+ const logbookDir = getLogbookDirPath(config, cwd);
34
35
  const allDirs = await fs.readdir(logbookDir);
35
36
  const matchingDir = allDirs.find((d) => {
36
37
  const prefix = d.split('-').slice(0, resolvedId.split('-').length).join('-');
37
38
  return prefix.toLowerCase() === resolvedId.toLowerCase();
38
39
  });
39
40
  if (!matchingDir) {
40
- console.error(chalk.red(`Error: No entry found matching ID '${resolvedId}' in ${config.logbookDir}/.`));
41
- console.error(chalk.red(`Run 'logbook new <id> <slug>' to create it first.`));
41
+ console.error(errorStyle(`Error: No entry found matching ID '${resolvedId}' in ${config.logbookDir}/.`));
42
+ console.error(errorStyle(`Run 'logbook new <id> <slug>' to create it first.`));
42
43
  process.exit(1);
43
44
  }
44
45
  const slug = matchingDir;
45
- const entryDir = join(logbookDir, slug);
46
+ const entryDir = getEntryPath(logbookDir, slug);
46
47
  // Warn if branch-resolved entry is different
47
48
  const activeFromBranch = await getActiveEntry({}, cwd);
48
49
  if (activeFromBranch && activeFromBranch.source === 'branch' && activeFromBranch.slug !== slug) {
49
- console.warn(chalk.yellow(`Warning: Your current Git branch resolves to active entry '${activeFromBranch.slug}', but you are manually starting '${slug}'.`));
50
+ console.warn(warningStyle(`Warning: Your current Git branch resolves to active entry '${activeFromBranch.slug}', but you are manually starting '${slug}'.`));
50
51
  }
51
52
  // 3. Detect git prompter
52
53
  const prompter = getGitUsername();
@@ -65,23 +66,27 @@ export async function start(id) {
65
66
  let raw = await fs.readFile(indexPath, 'utf8');
66
67
  const parsed = matter(raw);
67
68
  // Inject dateStart only if the placeholder exists (preserve existing dateStart on re-start)
68
- // Note: gray-matter parses [DATE_START] as a YAML array, so we check the raw string directly.
69
- if (raw.includes('dateStart: [DATE_START]')) {
70
- raw = raw.replace('dateStart: [DATE_START]', `dateStart: ${lockData.startedAt}`);
69
+ if (raw.includes('dateStart: "[DATE_START]"')) {
70
+ raw = raw.replace('dateStart: "[DATE_START]"', `dateStart: ${lockData.startedAt}`);
71
71
  }
72
72
  // Inject prompter if available and not already set
73
- if (prompter && !parsed.data.prompter) {
74
- raw = raw.replace(/^(---\n[\s\S]*?)(^---$)/m, (_, frontmatter, closing) => {
75
- if (frontmatter.includes('prompter:'))
76
- return _;
77
- return `${frontmatter}prompter: '${prompter.replace(/'/g, "''")}'\n${closing}`;
78
- });
73
+ if (prompter) {
74
+ if (raw.includes('prompter: "[PROMPTER]"')) {
75
+ raw = raw.replace('prompter: "[PROMPTER]"', `prompter: '${prompter.replace(/'/g, "''")}'`);
76
+ }
77
+ else if (!parsed.data.prompter) {
78
+ raw = raw.replace(/^(---\n[\s\S]*?)(^---$)/m, (_, frontmatter, closing) => {
79
+ if (frontmatter.includes('prompter:'))
80
+ return _;
81
+ return `${frontmatter}prompter: '${prompter.replace(/'/g, "''")}'\n${closing}`;
82
+ });
83
+ }
79
84
  }
80
85
  await fs.writeFile(indexPath, raw);
81
86
  }
82
- console.log(chalk.green(`Started entry: ${slug}`));
87
+ console.log(successMessage('Started', `entry: ${slug}`));
83
88
  if (prompter) {
84
- console.log(chalk.blue(`Prompter set to: ${prompter}`));
89
+ console.log(header(`Prompter set to: ${prompter}`));
85
90
  }
86
- console.log(chalk.gray(`Lockfile written to ${LOCKFILE_NAME}. Run 'logbook release' when done.`));
91
+ console.log(neutral(`Lockfile written to ${LOCKFILE_NAME}. Run 'logbook release' when done.`));
87
92
  }
@@ -0,0 +1 @@
1
+ export declare function status(): Promise<void>;
@@ -0,0 +1,122 @@
1
+ import fs from 'fs-extra';
2
+ import { getConfig, getLogbookDirPath } from '../lib/config.js';
3
+ import { getLogbookEntries } from '../utils/fs.js';
4
+ import { getActiveEntry } from '../lib/session.js';
5
+ import { parseTicketId } from '../utils/id.js';
6
+ import { error, neutral, highlight, bold, warning } from '../lib/theme.js';
7
+ import { getChangedLOC } from '../lib/git-helpers.js';
8
+ export async function status() {
9
+ const config = getConfig();
10
+ const logbookDir = getLogbookDirPath(config);
11
+ if (!(await fs.pathExists(logbookDir))) {
12
+ console.error(error(`Error: Logbook directory '${config.logbookDir}' not found. Run 'logbook init' first.`));
13
+ process.exit(1);
14
+ }
15
+ // Detect currently active entry (if any)
16
+ const active = await getActiveEntry();
17
+ const activeSlug = active?.slug;
18
+ const logbookEntries = await getLogbookEntries(logbookDir);
19
+ let totalCount = logbookEntries.length;
20
+ let draftCount = 0;
21
+ let doneCount = 0;
22
+ let activeCount = 0;
23
+ for (const entry of logbookEntries) {
24
+ const isActive = activeSlug === entry.slug;
25
+ if (isActive) {
26
+ activeCount++;
27
+ }
28
+ if (!entry.hasIndex) {
29
+ draftCount++;
30
+ continue;
31
+ }
32
+ const { data, content } = entry;
33
+ // Check frontmatter field values for bracket-style unfilled placeholders
34
+ const dataValues = Object.values(data)
35
+ .filter((v) => typeof v === 'string')
36
+ .join('\n');
37
+ const frontmatterHasPlaceholders = dataValues.includes('[DATE_END]') ||
38
+ dataValues.includes('[DATE_START]') ||
39
+ dataValues.includes('[WRITE_SUMMARY_HERE]') ||
40
+ dataValues.includes('[PROMPTER]') ||
41
+ dataValues.includes('[HARNESS]') ||
42
+ dataValues.includes('[LLM]');
43
+ // Check body content for unfilled template hints (strip inline code spans to avoid false positives)
44
+ const contentWithoutCode = content.replace(/`[^`]*`/g, '');
45
+ const bodyHasPlaceholders = contentWithoutCode.includes('TODO:') ||
46
+ content.includes('Write a polished, highly readable "short story" of the change here.');
47
+ const hasPlaceholders = frontmatterHasPlaceholders || bodyHasPlaceholders;
48
+ if (hasPlaceholders) {
49
+ draftCount++;
50
+ }
51
+ else if (!isActive) {
52
+ doneCount++;
53
+ }
54
+ }
55
+ // Find latest entry (sorted descending by ticket id / slug)
56
+ const sortedEntries = [...logbookEntries].sort((a, b) => {
57
+ const idA = parseTicketId(a.slug);
58
+ const idB = parseTicketId(b.slug);
59
+ if (idA && idB) {
60
+ if (idA.prefix !== idB.prefix) {
61
+ return idB.prefix.localeCompare(idA.prefix);
62
+ }
63
+ return idB.number - idA.number;
64
+ }
65
+ return b.slug.localeCompare(a.slug);
66
+ });
67
+ const latestEntry = sortedEntries[0];
68
+ let latestText = neutral('None');
69
+ if (latestEntry) {
70
+ const latestTitle = latestEntry.data?.title || latestEntry.slug.replace(/^[A-Za-z]+-\d+-/, '').replace(/-/g, ' ');
71
+ latestText = `${highlight(latestEntry.slug)} - ${latestTitle}`;
72
+ }
73
+ // Print Active Task section
74
+ console.log(`\n${bold.underline('Logbook Status')}`);
75
+ console.log(`\n${bold('Active Task:')}`);
76
+ if (active) {
77
+ const activeDetail = logbookEntries.find((e) => e.slug === active.slug);
78
+ const activeTitle = activeDetail?.data?.title || active.slug.replace(/^[A-Za-z]+-\d+-/, '').replace(/-/g, ' ');
79
+ console.log(` ${bold('Slug/ID:')} ${highlight(active.slug)}`);
80
+ console.log(` ${bold('Title:')} ${activeTitle}`);
81
+ console.log(` ${bold('Source:')} ${warning(active.source)}`);
82
+ if (active.startedAt) {
83
+ console.log(` ${bold('Started:')} ${active.startedAt}`);
84
+ }
85
+ if (active.prompter) {
86
+ console.log(` ${bold('Prompter:')} ${active.prompter}`);
87
+ }
88
+ }
89
+ else {
90
+ console.log(` ${neutral('No active task detected.')}`);
91
+ }
92
+ // Calculate changed LOC
93
+ try {
94
+ const summary = await getChangedLOC(config.logbookDir);
95
+ const activeLOC = `${summary.total} (+${summary.insertions}, -${summary.deletions})`;
96
+ console.log(`\n${bold('Changed LOC Count:')} ${highlight(activeLOC)}\n`);
97
+ }
98
+ catch { }
99
+ // Print Settings section
100
+ console.log(`\n${bold('Settings (.project-logbook):')}`);
101
+ console.log(` ${bold('Project Name:')} ${config.projectName}`);
102
+ console.log(` ${bold('Logbook Directory:')} ${config.logbookDir}`);
103
+ console.log(` ${bold('Output Directory:')} ${config.outputDir}`);
104
+ console.log(` ${bold('Primary Color:')} ${config.primaryColor}`);
105
+ if (config.jiraBaseUrl) {
106
+ console.log(` ${bold('Jira Base URL:')} ${config.jiraBaseUrl}`);
107
+ }
108
+ if (config.jiraPrefix) {
109
+ console.log(` ${bold('Jira Prefix:')} ${config.jiraPrefix}`);
110
+ }
111
+ if (config.tags?.allowed) {
112
+ console.log(` ${bold('Allowed Tags:')} ${config.tags.allowed.join(', ')}`);
113
+ }
114
+ // Print Stats section
115
+ console.log(`\n${bold('Stats:')}`);
116
+ console.log(` ${bold('Total Entries:')} ${totalCount}`);
117
+ console.log(` ${bold('Active Tasks:')} ${activeCount}`);
118
+ console.log(` ${bold('Completed Done:')} ${doneCount}`);
119
+ console.log(` ${bold('Ticket Drafts:')} ${draftCount}`);
120
+ console.log(` ${bold('Latest Entry:')} ${latestText}`);
121
+ console.log('');
122
+ }
@@ -1,22 +1,22 @@
1
1
  import fs from 'fs-extra';
2
2
  import { join } from 'node:path';
3
- import chalk from 'chalk';
3
+ import { warning as warningStyle, header, error as errorStyle, success, errorMessage, bold, highlight, } from '../lib/theme.js';
4
4
  import { getTemplatesDir } from '../lib/migrations.js';
5
5
  export async function steer() {
6
6
  const steerPath = join(getTemplatesDir(), 'steer.txt');
7
7
  if (!fs.existsSync(steerPath)) {
8
- console.error(chalk.red('Error: Steering template not found.'));
8
+ console.error(errorMessage('Error', 'Steering template not found.'));
9
9
  return;
10
10
  }
11
11
  const protocol = fs.readFileSync(steerPath, 'utf8');
12
12
  // Add some color to headers and mandatory parts
13
13
  const formatted = protocol
14
- .replace(/^PROJECT LOGBOOK:.+$/m, (m) => chalk.bold.cyan(m))
15
- .replace(/^Phase \d:.+$/gm, (m) => chalk.bold(m))
16
- .replace(/logbook \w+/g, (m) => chalk.green(m))
17
- .replace(/\w+\.md/g, (m) => chalk.blue(m))
18
- .replace(/Mandatory:/g, chalk.yellow('Mandatory:'))
19
- .replace(/Storytelling Mode:/g, chalk.yellow('Storytelling Mode:'))
20
- .replace(/Quality Gate:/g, chalk.red('Quality Gate:'));
14
+ .replace(/^PROJECT LOGBOOK:.+$/m, (m) => bold(highlight(m)))
15
+ .replace(/^Phase \d:.+$/gm, (m) => bold(m))
16
+ .replace(/logbook \w+/g, (m) => success(m))
17
+ .replace(/\w+\.md/g, (m) => header(m))
18
+ .replace(/Mandatory:/g, warningStyle('Mandatory:'))
19
+ .replace(/Storytelling Mode:/g, warningStyle('Storytelling Mode:'))
20
+ .replace(/Quality Gate:/g, errorStyle('Quality Gate:'));
21
21
  console.log(formatted);
22
22
  }
@@ -1,23 +1,23 @@
1
1
  import fs from 'fs-extra';
2
- import chalk from 'chalk';
2
+ import { success, header } from '../lib/theme.js';
3
3
  import { getProjectStatus } from '../lib/migrations.js';
4
4
  export async function upgrade() {
5
5
  const projectStatus = await getProjectStatus();
6
6
  let updatedCount = 0;
7
7
  for (const file of projectStatus) {
8
8
  if (file.status === 'UP_TO_DATE') {
9
- console.log(chalk.blue(`[UP TO DATE] ${file.name}`));
9
+ console.log(header(`[UP TO DATE] ${file.name}`));
10
10
  }
11
11
  else {
12
12
  await fs.writeFile(file.localPath, file.templateContent);
13
- console.log(chalk.green(`[${file.status === 'MISSING' ? 'CREATED' : 'UPDATED'}] ${file.name}`));
13
+ console.log(success(`[${file.status === 'MISSING' ? 'CREATED' : 'UPDATED'}] ${file.name}`));
14
14
  updatedCount++;
15
15
  }
16
16
  }
17
17
  if (updatedCount === 0) {
18
- console.log(chalk.blue('\nEverything is already up to date.'));
18
+ console.log(header('\nEverything is already up to date.'));
19
19
  }
20
20
  else {
21
- console.log(chalk.green(`\nSuccessfully upgraded ${updatedCount} files.`));
21
+ console.log(success(`\nSuccessfully upgraded ${updatedCount} files.`));
22
22
  }
23
23
  }
package/dist/index.js CHANGED
@@ -1,14 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
- import { readFileSync } from 'node:fs';
4
- import { join, dirname } from 'node:path';
5
- import { fileURLToPath } from 'node:url';
6
- const __filename = fileURLToPath(import.meta.url);
7
- const __dirname = dirname(__filename);
8
- const pkgPath = join(__dirname, '..', 'package.json');
9
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
3
+ import { getPackageVersion } from './lib/package-version.js';
10
4
  const program = new Command();
11
- program.name('logbook').description('A CLI tool for managing project logbooks.').version(pkg.version);
5
+ program.name('logbook').description('A CLI tool for managing project logbooks.').version(getPackageVersion());
12
6
  program
13
7
  .command('init')
14
8
  .description('Initialize .project-logbook config and /logbook folder')
@@ -74,6 +68,13 @@ program
74
68
  const { list } = await import('./commands/list.js');
75
69
  await list(options);
76
70
  });
71
+ program
72
+ .command('status')
73
+ .description('Show current active logbook task, configuration settings, and statistics')
74
+ .action(async () => {
75
+ const { status } = await import('./commands/status.js');
76
+ await status();
77
+ });
77
78
  program
78
79
  .command('log <message>')
79
80
  .description('Append a timestamped log entry to the active log.md (e.g. logbook log "Did a thing")')
@@ -1,6 +1,8 @@
1
1
  import type { LogbookConfig } from './config.js';
2
2
  import type { TimelineEntry } from './template-types.js';
3
3
  export declare function mdToHtml(md: string): Promise<string>;
4
+ /** Process entry markdown with image path rewriting */
5
+ export declare function mdToHtmlWithImagePathRewrite(md: string): Promise<string>;
4
6
  export declare function buildProjectMdFiles(projectRoot: string, outputDir: string, excludeDirs: string[], ctx: {
5
7
  config: LogbookConfig;
6
8
  version: string;
@@ -13,3 +15,8 @@ export declare function renderPost(data: TimelineEntry, i: number, allEntries: T
13
15
  version: string;
14
16
  buildTime: string;
15
17
  }): Promise<void>;
18
+ /**
19
+ * Generate the standard footer metadata string.
20
+ * Always hardcoded to 'Project Logbook CLI' with link to npm.
21
+ */
22
+ export declare function generateBuildMeta(version: string, buildTime: string): string;
@@ -1,24 +1,22 @@
1
1
  import fs from 'fs-extra';
2
- import { join } 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';
2
+ import { join, resolve } from 'node:path';
10
3
  import matter from 'gray-matter';
11
4
  import { layout, postTemplate } from './templates.js';
12
5
  import { getGitCommits } from './git-helpers.js';
13
- import { mdToHtmlWithImages, extractImagePathsFromMarkdown, copyImages } from './image-helpers.js';
6
+ import { visitHastElements } from './hast-helpers.js';
7
+ import { createGeneralMarkdownProcessor, createEntryMarkdownProcessor } from './markdown-processors.js';
8
+ import { isExternalUrl, isExternalUrlOrAnchor } from './url-helpers.js';
9
+ import { readFileIfExists, pathExistsOrNull } from '../utils/fs.js';
10
+ import { getEntryFilePaths } from './entry-paths.js';
11
+ import { mdToHtmlWithImages, extractImagePathsFromMarkdown, copyImages, rehypeRewriteImagePaths, } from './image-helpers.js';
14
12
  /** Rehype plugin: rewrite relative .md links to /index.html equivalents. */
15
13
  const rehypeRewriteMdLinks = () => {
16
14
  return (tree) => {
17
- visitLinks(tree, (node) => {
15
+ visitHastElements(tree, 'a', (node) => {
18
16
  const href = node.properties?.href;
19
17
  if (typeof href !== 'string')
20
18
  return;
21
- if (/^[a-z][a-z\d+\-.]*:/i.test(href) || href.startsWith('#'))
19
+ if (isExternalUrlOrAnchor(href))
22
20
  return;
23
21
  const mdMatch = href.match(/^(.*?)\.md(#.*)?$/i);
24
22
  if (!mdMatch)
@@ -29,27 +27,17 @@ const rehypeRewriteMdLinks = () => {
29
27
  });
30
28
  };
31
29
  };
32
- function visitLinks(node, visitor) {
33
- for (const child of node.children) {
34
- if (child.type === 'element') {
35
- if (child.tagName === 'a')
36
- visitor(child);
37
- visitLinks(child, visitor);
38
- }
39
- }
40
- }
41
- const processor = unified()
42
- .use(remarkParse)
43
- .use(remarkGfm)
44
- .use(remarkRehype)
45
- .use(rehypeSlug)
46
- .use(rehypeRewriteMdLinks)
47
- .use(rehypeFormat)
48
- .use(rehypeStringify);
30
+ const processor = createGeneralMarkdownProcessor(rehypeRewriteMdLinks);
31
+ const entryProcessor = createEntryMarkdownProcessor(rehypeRewriteMdLinks, rehypeRewriteImagePaths);
49
32
  export async function mdToHtml(md) {
50
33
  const result = await processor.process(md);
51
34
  return result.toString();
52
35
  }
36
+ /** Process entry markdown with image path rewriting */
37
+ export async function mdToHtmlWithImagePathRewrite(md) {
38
+ const result = await entryProcessor.process(md);
39
+ return result.toString();
40
+ }
53
41
  export async function buildProjectMdFiles(projectRoot, outputDir, excludeDirs, ctx) {
54
42
  const entries = await fs.readdir(projectRoot, { withFileTypes: true });
55
43
  for (const entry of entries) {
@@ -62,7 +50,7 @@ export async function buildProjectMdFiles(projectRoot, outputDir, excludeDirs, c
62
50
  const outDir = join(outputDir, stem);
63
51
  const mdContent = await fs.readFile(sourcePath, 'utf8');
64
52
  const bodyHtml = await mdToHtml(mdContent);
65
- const buildMeta = `Generated by ${ctx.config.projectName} v${ctx.version} • ${ctx.buildTime}`;
53
+ const buildMeta = generateBuildMeta(ctx.version, ctx.buildTime);
66
54
  const pageHtml = layout({
67
55
  title: stem,
68
56
  projectName: ctx.config.projectName,
@@ -88,22 +76,28 @@ async function renderLinkedMdFiles(markdownSources, entryPath, entryOutputDir, c
88
76
  const href = raw.split('#')[0];
89
77
  if (!href)
90
78
  continue;
91
- if (/^[a-z][a-z\d+\-.]*:/i.test(href))
79
+ if (isExternalUrl(href))
92
80
  continue;
93
81
  if (seen.has(href))
94
82
  continue;
95
83
  seen.add(href);
96
84
  let sourcePath = join(entryPath, href);
97
- if (!(await fs.pathExists(sourcePath)))
98
- sourcePath = join(process.cwd(), href);
99
- if (!(await fs.pathExists(sourcePath))) {
85
+ sourcePath = (await pathExistsOrNull(sourcePath)) ?? join(process.cwd(), href);
86
+ if (!sourcePath || !(await pathExistsOrNull(sourcePath))) {
100
87
  console.warn(` Linked file not found, skipping: ${href}`);
101
88
  continue;
102
89
  }
90
+ // Path traversal protection: ensure resolved path stays within project boundaries
91
+ const resolvedPath = resolve(sourcePath);
92
+ const projectRoot = resolve(process.cwd());
93
+ if (!resolvedPath.startsWith(projectRoot + '/') && resolvedPath !== projectRoot) {
94
+ console.warn(` Path traversal blocked: ${href}`);
95
+ continue;
96
+ }
103
97
  const mdContent = await fs.readFile(sourcePath, 'utf8');
104
98
  const bodyHtml = await mdToHtml(mdContent);
105
99
  const title = href.replace(/\.md$/i, '');
106
- const buildMeta = `Generated by ${ctx.config.projectName} v${ctx.version} • ${ctx.buildTime}`;
100
+ const buildMeta = generateBuildMeta(ctx.version, ctx.buildTime);
107
101
  const pageHtml = layout({
108
102
  title,
109
103
  projectName: ctx.config.projectName,
@@ -120,12 +114,11 @@ async function renderLinkedMdFiles(markdownSources, entryPath, entryOutputDir, c
120
114
  }
121
115
  }
122
116
  export async function renderPost(data, i, allEntries, ctx) {
123
- const entryPath = join(ctx.logbookDir, data.slug);
124
- const ticketPath = join(entryPath, 'ticket.md');
125
- const logPath = join(entryPath, 'log.md');
126
- const { content, content: summaryContent } = matter(await fs.readFile(join(entryPath, 'index.md'), 'utf8'));
127
- const ticketRaw = (await fs.pathExists(ticketPath)) ? await fs.readFile(ticketPath, 'utf8') : '';
128
- const logRaw = (await fs.pathExists(logPath)) ? await fs.readFile(logPath, 'utf8') : '';
117
+ const entryFilePaths = getEntryFilePaths(ctx.logbookDir, data.slug);
118
+ const entryPath = entryFilePaths.index.replace(/\/index\.md$/, '');
119
+ const { content, content: summaryContent } = matter(await fs.readFile(entryFilePaths.index, 'utf8'));
120
+ const ticketRaw = await readFileIfExists(entryFilePaths.ticket);
121
+ const logRaw = await readFileIfExists(entryFilePaths.log);
129
122
  // Process with image collection for index.md content
130
123
  const { html: storyHtml, imagePaths: storyImages } = await mdToHtmlWithImages(content.replace(/^#\s+.+$/m, '').trim());
131
124
  // Collect images from all markdown sources
@@ -136,13 +129,13 @@ export async function renderPost(data, i, allEntries, ctx) {
136
129
  const jiraUrl = ctx.config.jiraBaseUrl && data.ticket ? `${ctx.config.jiraBaseUrl}${data.ticket}` : undefined;
137
130
  const prev = i > 0 ? allEntries[i - 1] : null;
138
131
  const next = i < allEntries.length - 1 ? allEntries[i + 1] : null;
139
- const postContent = postTemplate({
132
+ const { header: postHeader, content: postMainContent } = postTemplate({
140
133
  ...data,
141
134
  title: data.title ?? data.slug,
142
135
  harness: data.harness ?? '',
143
136
  content: storyHtml,
144
- ticketHtml: ticketRaw ? await mdToHtml(ticketRaw) : '',
145
- logHtml: logRaw ? await mdToHtml(logRaw) : '',
137
+ ticketHtml: ticketRaw ? await mdToHtmlWithImagePathRewrite(ticketRaw) : '',
138
+ logHtml: logRaw ? await mdToHtmlWithImagePathRewrite(logRaw) : '',
146
139
  commits,
147
140
  version: ctx.version,
148
141
  jiraUrl,
@@ -150,7 +143,7 @@ export async function renderPost(data, i, allEntries, ctx) {
150
143
  nextEntry: next ? { slug: next.slug, title: next.title ?? '', ticket: next.ticket } : null,
151
144
  });
152
145
  const descriptionRaw = typeof data.summary === 'string' ? data.summary.replace(/[*#`]/g, '').trim() : '';
153
- const buildMeta = `Generated by ${ctx.config.projectName} v${ctx.version} • ${ctx.buildTime}`;
146
+ const buildMeta = generateBuildMeta(ctx.version, ctx.buildTime);
154
147
  const postHtml = layout({
155
148
  title: data.title ?? data.slug,
156
149
  projectName: ctx.config.projectName,
@@ -158,8 +151,8 @@ export async function renderPost(data, i, allEntries, ctx) {
158
151
  description: descriptionRaw.substring(0, 160),
159
152
  bodySlug: data.slug,
160
153
  buildMeta,
161
- header: postContent.split('</header>')[0] + '</header>',
162
- content: postContent.split('</header>')[1],
154
+ header: postHeader,
155
+ content: postMainContent,
163
156
  });
164
157
  const entryOutputDir = join(ctx.outputDir, data.slug);
165
158
  await fs.mkdirp(entryOutputDir);
@@ -171,3 +164,10 @@ export async function renderPost(data, i, allEntries, ctx) {
171
164
  }
172
165
  await renderLinkedMdFiles([summaryContent, ticketRaw, logRaw], entryPath, entryOutputDir, ctx);
173
166
  }
167
+ /**
168
+ * Generate the standard footer metadata string.
169
+ * Always hardcoded to 'Project Logbook CLI' with link to npm.
170
+ */
171
+ export function generateBuildMeta(version, buildTime) {
172
+ return `Generated by <a href="https://www.npmjs.com/package/project-logbook" target="_blank" rel="noopener noreferrer">Project Logbook CLI</a> v${version} • ${buildTime}`;
173
+ }
@@ -0,0 +1,20 @@
1
+ import type { LogbookConfig } from './config.js';
2
+ /**
3
+ * Set up the output directory: create it, copy static assets (favicon, styles, script).
4
+ */
5
+ export declare function setupOutputDirectory(outputDir: string, config: LogbookConfig): Promise<void>;
6
+ /**
7
+ * Process README.md if it exists and return rendered HTML + image paths.
8
+ */
9
+ export declare function processReadme(projectRoot: string): Promise<{
10
+ html: string;
11
+ imagePaths: string[];
12
+ }>;
13
+ /**
14
+ * Copy README images to the output directory.
15
+ */
16
+ export declare function copyReadmeImages(projectRoot: string, outputDir: string, imagePaths: string[]): Promise<void>;
17
+ /**
18
+ * Process the About content and return rendered HTML.
19
+ */
20
+ export declare function processAboutContent(aboutContent: string): Promise<string>;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Helper functions for the build command.
3
+ * Breaks down the monolithic build() function into testable, reusable steps.
4
+ */
5
+ import fs from 'fs-extra';
6
+ import { join } from 'node:path';
7
+ import { warning } from './theme.js';
8
+ import { URL } from 'node:url';
9
+ import { mdToHtmlWithImages, copyImages } from './image-helpers.js';
10
+ import { getStyles } from './styles.js';
11
+ import { getClientScript } from './logbook-client.js';
12
+ import { mdToHtml } from './build-helpers.js';
13
+ /**
14
+ * Set up the output directory: create it, copy static assets (favicon, styles, script).
15
+ */
16
+ export async function setupOutputDirectory(outputDir, config) {
17
+ await fs.remove(outputDir);
18
+ await fs.mkdirp(outputDir);
19
+ await fs.copyFile(new URL('../templates/favicon.svg', import.meta.url), join(outputDir, 'favicon.svg'));
20
+ await fs.writeFile(join(outputDir, 'style.css'), getStyles(config.primaryColor));
21
+ await fs.writeFile(join(outputDir, 'logbook.js'), getClientScript());
22
+ }
23
+ /**
24
+ * Process README.md if it exists and return rendered HTML + image paths.
25
+ */
26
+ export async function processReadme(projectRoot) {
27
+ const readmePath = join(projectRoot, 'README.md');
28
+ let readmeHtml = '';
29
+ let readmeImages = [];
30
+ try {
31
+ if (await fs.pathExists(readmePath)) {
32
+ const readmeContent = await fs.readFile(readmePath, 'utf8');
33
+ const { html, imagePaths } = await mdToHtmlWithImages(readmeContent);
34
+ readmeHtml = html;
35
+ readmeImages = imagePaths;
36
+ }
37
+ }
38
+ catch (error) {
39
+ console.warn(warning(`Warning: Failed to read README.md: ${error instanceof Error ? error.message : error}`));
40
+ readmeHtml = '<p>README unavailable.</p>';
41
+ }
42
+ return { html: readmeHtml, imagePaths: readmeImages };
43
+ }
44
+ /**
45
+ * Copy README images to the output directory.
46
+ */
47
+ export async function copyReadmeImages(projectRoot, outputDir, imagePaths) {
48
+ if (imagePaths.length > 0) {
49
+ await copyImages(projectRoot, outputDir, imagePaths);
50
+ }
51
+ }
52
+ /**
53
+ * Process the About content and return rendered HTML.
54
+ */
55
+ export async function processAboutContent(aboutContent) {
56
+ return await mdToHtml(aboutContent);
57
+ }
@@ -12,3 +12,19 @@ export interface LogbookConfig {
12
12
  }
13
13
  export declare const DEFAULT_CONFIG: LogbookConfig;
14
14
  export declare function getConfig(cwd?: string): LogbookConfig;
15
+ /**
16
+ * Get the absolute path to the logbook directory.
17
+ * Convenience function to avoid repeating: join(process.cwd(), config.logbookDir)
18
+ * @param config - The logbook config
19
+ * @param cwd - Optional working directory (defaults to process.cwd())
20
+ * @returns Absolute path to the logbook directory
21
+ */
22
+ export declare function getLogbookDirPath(config: LogbookConfig, cwd?: string): string;
23
+ /**
24
+ * Get the absolute path to the output directory.
25
+ * Convenience function to avoid repeating: join(process.cwd(), config.outputDir)
26
+ * @param config - The logbook config
27
+ * @param cwd - Optional working directory (defaults to process.cwd())
28
+ * @returns Absolute path to the output directory
29
+ */
30
+ export declare function getOutputDirPath(config: LogbookConfig, cwd?: string): string;
@@ -39,3 +39,23 @@ export function getConfig(cwd = process.cwd()) {
39
39
  }
40
40
  return DEFAULT_CONFIG;
41
41
  }
42
+ /**
43
+ * Get the absolute path to the logbook directory.
44
+ * Convenience function to avoid repeating: join(process.cwd(), config.logbookDir)
45
+ * @param config - The logbook config
46
+ * @param cwd - Optional working directory (defaults to process.cwd())
47
+ * @returns Absolute path to the logbook directory
48
+ */
49
+ export function getLogbookDirPath(config, cwd = process.cwd()) {
50
+ return join(cwd, config.logbookDir);
51
+ }
52
+ /**
53
+ * Get the absolute path to the output directory.
54
+ * Convenience function to avoid repeating: join(process.cwd(), config.outputDir)
55
+ * @param config - The logbook config
56
+ * @param cwd - Optional working directory (defaults to process.cwd())
57
+ * @returns Absolute path to the output directory
58
+ */
59
+ export function getOutputDirPath(config, cwd = process.cwd()) {
60
+ return join(cwd, config.outputDir);
61
+ }