project-logbook 0.3.4 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/commands/build.js +25 -2
  2. package/dist/commands/lint.js +18 -0
  3. package/dist/commands/status.js +23 -7
  4. package/dist/lib/build-helpers.js +7 -22
  5. package/dist/lib/build-steps.d.ts +4 -0
  6. package/dist/lib/build-steps.js +18 -1
  7. package/dist/lib/config.d.ts +1 -0
  8. package/dist/lib/config.js +7 -3
  9. package/dist/lib/git-helpers.d.ts +21 -0
  10. package/dist/lib/git-helpers.js +91 -0
  11. package/dist/lib/image-helpers.js +2 -2
  12. package/dist/lib/markdown-processors.d.ts +5 -1
  13. package/dist/lib/markdown-processors.js +28 -2
  14. package/dist/lib/rss.d.ts +29 -0
  15. package/dist/lib/rss.js +77 -0
  16. package/dist/lib/template-helpers.d.ts +3 -3
  17. package/dist/lib/template-helpers.js +19 -6
  18. package/dist/lib/template-types.d.ts +3 -0
  19. package/dist/lib/templates.d.ts +5 -2
  20. package/dist/lib/templates.js +24 -14
  21. package/dist/linters/index.js +2 -0
  22. package/dist/linters/technical-log.d.ts +7 -0
  23. package/dist/linters/technical-log.js +72 -0
  24. package/dist/templates/WELCOME.md +12 -0
  25. package/dist/templates/index.md +4 -0
  26. package/dist/templates/log.md +5 -0
  27. package/dist/templates/logbook-client.js +29 -0
  28. package/dist/templates/steer.txt +21 -5
  29. package/dist/templates/styles.css +182 -0
  30. package/dist/utils/date.d.ts +7 -0
  31. package/dist/utils/date.js +12 -0
  32. package/dist/utils/log-timeline.d.ts +69 -0
  33. package/dist/utils/log-timeline.js +218 -0
  34. package/package.json +3 -1
  35. package/src/templates/WELCOME.md +12 -0
  36. package/src/templates/index.md +4 -0
  37. package/src/templates/log.md +5 -0
  38. package/src/templates/logbook-client.js +29 -0
  39. package/src/templates/steer.txt +21 -5
  40. package/src/templates/styles.css +182 -0
@@ -5,7 +5,7 @@ import { getConfig, getWorkspaces, getLogbookDirPath, getOutputDirPath } from '.
5
5
  import { layout, timelineTemplate } from '../lib/templates.js';
6
6
  import { buildProjectMdFiles, renderPost, generateBuildMeta } from '../lib/build-helpers.js';
7
7
  import { getGitCommits, getGitTags } from '../lib/git-helpers.js';
8
- import { setupOutputDirectory, processReadme, copyReadmeImages, processAboutContent } from '../lib/build-steps.js';
8
+ import { setupOutputDirectory, processReadme, copyReadmeImages, processAboutContent, processWelcomeContent, } from '../lib/build-steps.js';
9
9
  import { formatAbsoluteDate, getMonthYear, getSortTime, formatDateTimeForDisplay } from '../utils/date.js';
10
10
  import { getLogbookEntries } from '../utils/fs.js';
11
11
  import { toDisplayString, asString, asStringArray } from '../utils/frontmatter.js';
@@ -13,6 +13,7 @@ import { getAboutContent } from '../lib/about-content.js';
13
13
  import { getPackageVersion } from '../lib/package-version.js';
14
14
  import { groupTimelineItems } from '../utils/timeline-helpers.js';
15
15
  import { getEntryDisplayId, getEntryDisplayTitle } from '../lib/entry-id.js';
16
+ import { generateRssFeed, validateRssXml } from '../lib/rss.js';
16
17
  const version = getPackageVersion();
17
18
  export async function build() {
18
19
  const config = getConfig();
@@ -28,6 +29,7 @@ export async function build() {
28
29
  const { html: readmeHtml, imagePaths: readmeImages } = await processReadme(projectRoot);
29
30
  await copyReadmeImages(projectRoot, outputDir, readmeImages);
30
31
  const aboutHtml = await processAboutContent(getAboutContent());
32
+ const welcomeHtml = await processWelcomeContent();
31
33
  const logbookEntries = await getLogbookEntries(logbookDir);
32
34
  const timelineEntries = [];
33
35
  const availableWorkspaces = getWorkspaces();
@@ -53,7 +55,7 @@ export async function build() {
53
55
  if (!dateStart || dateStart.includes('{{') || dateStart === '[DATE_START]')
54
56
  continue;
55
57
  const dateEndValue = data.dateEnd;
56
- const dateEnd = typeof dateEndValue === 'string'
58
+ const dateEnd = typeof dateEndValue === 'string' && dateEndValue !== '[DATE_END]'
57
59
  ? dateEndValue
58
60
  : dateEndValue instanceof Date
59
61
  ? dateEndValue.toISOString()
@@ -128,10 +130,13 @@ export async function build() {
128
130
  groups: pageGroups,
129
131
  readmeHtml,
130
132
  aboutHtml,
133
+ welcomeHtml,
131
134
  jiraBaseUrl: config.jiraBaseUrl,
132
135
  jiraPrefix: config.jiraPrefix,
136
+ repositoryUrl: config.repositoryUrl,
133
137
  currentPage,
134
138
  totalPages,
139
+ includeRssLink: currentPage === 1, // Only show RSS link on first page
135
140
  }); // prettier-ignore
136
141
  const buildMeta = generateBuildMeta(version, buildTime);
137
142
  const timelineHtml = layout({
@@ -141,6 +146,7 @@ export async function build() {
141
146
  buildMeta,
142
147
  header: timelineHeader,
143
148
  content: timelineMainContent,
149
+ includeRssLink: currentPage === 1, // Only show RSS link on first page
144
150
  });
145
151
  const fileName = currentPage === 1 ? 'index.html' : `index-${currentPage}.html`;
146
152
  await fs.writeFile(join(outputDir, fileName), timelineHtml);
@@ -150,5 +156,22 @@ export async function build() {
150
156
  version,
151
157
  buildTime,
152
158
  });
159
+ // Generate RSS feed
160
+ const rssConfig = {
161
+ title: `${config.projectName} - Recent Updates`,
162
+ description: `Latest logbook entries from ${config.projectName}`,
163
+ language: 'en',
164
+ feed_url: `${config.projectName.toLowerCase().replace(/\s+/g, '-')}/rss.xml`,
165
+ site_url: config.projectName,
166
+ generator: `Project Logbook CLI v${version}`,
167
+ };
168
+ const rssXml = generateRssFeed(timelineEntries, rssConfig);
169
+ // Basic validation
170
+ if (!validateRssXml(rssXml)) {
171
+ console.error(error('Error: Generated RSS feed failed basic validation.'));
172
+ return;
173
+ }
174
+ await fs.writeFile(join(outputDir, 'rss.xml'), rssXml);
175
+ console.log(neutral(` Generated RSS feed with ${Math.min(50, timelineEntries.length)} entries`));
153
176
  console.log(success(`\nSuccessfully built logbook to ${config.outputDir}/`));
154
177
  }
@@ -1,8 +1,11 @@
1
1
  import fs from 'fs-extra';
2
+ import { join } from 'node:path';
2
3
  import { error as errorStyle, header, success as successStyle, neutral as neutralStyle } from '../lib/theme.js';
3
4
  import { getConfig, getLogbookDirPath } from '../lib/config.js';
4
5
  import { runLinters } from '../lib/lint-runner.js';
5
6
  import { getLogbookEntries } from '../utils/fs.js';
7
+ import { getActiveEntry } from '../lib/session.js';
8
+ import { getLastLogEntry } from '../utils/log-timeline.js';
6
9
  export async function lint() {
7
10
  const config = getConfig();
8
11
  const logbookDir = getLogbookDirPath(config);
@@ -11,6 +14,21 @@ export async function lint() {
11
14
  return;
12
15
  }
13
16
  let overallSuccess = true;
17
+ // Show last log entry nudge if an entry is active
18
+ const active = await getActiveEntry();
19
+ if (active) {
20
+ const logPath = join(logbookDir, active.slug, 'log.md');
21
+ if (await fs.pathExists(logPath)) {
22
+ const logContent = await fs.readFile(logPath, 'utf8');
23
+ const lastEntry = getLastLogEntry(logContent);
24
+ if (lastEntry) {
25
+ const truncated = lastEntry.message.length > 80 ? lastEntry.message.slice(0, 77) + '...' : lastEntry.message;
26
+ console.log(header(`Active entry: ${active.slug}`));
27
+ console.log(` Last Log: ${lastEntry.isoTimestamp}: ${truncated}`);
28
+ console.log(neutralStyle(` → Use 'logbook log "<message>"' if there's anything to add.\n`));
29
+ }
30
+ }
31
+ }
14
32
  // 1. Project-level checks
15
33
  console.log(header('Checking project integrity...'));
16
34
  const projectSuccess = await runLinters({ config });
@@ -1,10 +1,12 @@
1
1
  import fs from 'fs-extra';
2
+ import { join } from 'node:path';
2
3
  import { getConfig, getLogbookDirPath } from '../lib/config.js';
3
4
  import { getLogbookEntries } from '../utils/fs.js';
4
5
  import { getActiveEntry } from '../lib/session.js';
5
6
  import { parseTicketId } from '../utils/id.js';
6
7
  import { error, neutral, highlight, bold, warning } from '../lib/theme.js';
7
8
  import { getChangedLOC } from '../lib/git-helpers.js';
9
+ import { getLastLogEntry } from '../utils/log-timeline.js';
8
10
  export async function status() {
9
11
  const config = getConfig();
10
12
  const logbookDir = getLogbookDirPath(config);
@@ -70,9 +72,9 @@ export async function status() {
70
72
  const latestTitle = latestEntry.data?.title || latestEntry.slug.replace(/^[A-Za-z]+-\d+-/, '').replace(/-/g, ' ');
71
73
  latestText = `${highlight(latestEntry.slug)} - ${latestTitle}`;
72
74
  }
73
- // Print Active Task section
75
+ // Print Active Ticket section
74
76
  console.log(`\n${bold.underline('Logbook Status')}`);
75
- console.log(`\n${bold('Active Task:')}`);
77
+ console.log(`\n${bold('Active Ticket:')}`);
76
78
  if (active) {
77
79
  const activeDetail = logbookEntries.find((e) => e.slug === active.slug);
78
80
  const activeTitle = activeDetail?.data?.title || active.slug.replace(/^[A-Za-z]+-\d+-/, '').replace(/-/g, ' ');
@@ -85,9 +87,20 @@ export async function status() {
85
87
  if (active.prompter) {
86
88
  console.log(` ${bold('Prompter:')} ${active.prompter}`);
87
89
  }
90
+ // Show last log entry as a nudge to keep logging
91
+ const logPath = join(logbookDir, active.slug, 'log.md');
92
+ if (await fs.pathExists(logPath)) {
93
+ const logContent = await fs.readFile(logPath, 'utf8');
94
+ const lastEntry = getLastLogEntry(logContent);
95
+ if (lastEntry) {
96
+ const truncated = lastEntry.message.length > 80 ? lastEntry.message.slice(0, 77) + '...' : lastEntry.message;
97
+ console.log(` ${bold('Last Log:')} ${lastEntry.isoTimestamp}: ${truncated}`);
98
+ console.log(` ${neutral("→ Use 'logbook log \"<message>\"' if there's anything to add.")}`);
99
+ }
100
+ }
88
101
  }
89
102
  else {
90
- console.log(` ${neutral('No active task detected.')}`);
103
+ console.log(` ${neutral('No active ticket detected.')}`);
91
104
  }
92
105
  // Calculate changed LOC
93
106
  try {
@@ -108,15 +121,18 @@ export async function status() {
108
121
  if (config.jiraPrefix) {
109
122
  console.log(` ${bold('Jira Prefix:')} ${config.jiraPrefix}`);
110
123
  }
124
+ if (config.repositoryUrl) {
125
+ console.log(` ${bold('Repository URL:')} ${config.repositoryUrl}`);
126
+ }
111
127
  if (config.tags?.allowed) {
112
128
  console.log(` ${bold('Allowed Tags:')} ${config.tags.allowed.join(', ')}`);
113
129
  }
114
130
  // Print Stats section
115
- console.log(`\n${bold('Stats:')}`);
131
+ console.log(`\n${bold('Logbook stats:')}`);
116
132
  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}`);
133
+ console.log(` ${bold('Active:')} ${activeCount}`);
134
+ console.log(` ${bold('Done:')}. ${doneCount}`);
135
+ console.log(` ${bold('Drafts:')} ${draftCount}`);
120
136
  console.log(` ${bold('Latest Entry:')} ${latestText}`);
121
137
  console.log('');
122
138
  }
@@ -3,30 +3,12 @@ import { join, resolve } from 'node:path';
3
3
  import matter from 'gray-matter';
4
4
  import { layout, postTemplate } from './templates.js';
5
5
  import { getGitCommits } from './git-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';
6
+ import { createGeneralMarkdownProcessor, createEntryMarkdownProcessor, rehypeRewriteMdLinks, } from './markdown-processors.js';
7
+ import { isExternalUrl } from './url-helpers.js';
9
8
  import { readFileIfExists, pathExistsOrNull } from '../utils/fs.js';
10
9
  import { getEntryFilePaths } from './entry-paths.js';
11
10
  import { mdToHtmlWithImages, extractImagePathsFromMarkdown, copyImages, rehypeRewriteImagePaths, } from './image-helpers.js';
12
- /** Rehype plugin: rewrite relative .md links to /index.html equivalents. */
13
- 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
- };
11
+ import { transformLogToTimeline } from '../utils/log-timeline.js';
30
12
  const processor = createGeneralMarkdownProcessor(rehypeRewriteMdLinks);
31
13
  const entryProcessor = createEntryMarkdownProcessor(rehypeRewriteMdLinks, rehypeRewriteImagePaths);
32
14
  export async function mdToHtml(md) {
@@ -129,16 +111,19 @@ export async function renderPost(data, i, allEntries, ctx) {
129
111
  const jiraUrl = ctx.config.jiraBaseUrl && data.ticket ? `${ctx.config.jiraBaseUrl}${data.ticket}` : undefined;
130
112
  const prev = i > 0 ? allEntries[i - 1] : null;
131
113
  const next = i < allEntries.length - 1 ? allEntries[i + 1] : null;
114
+ // Transform log to timeline if possible, otherwise fall back to plain markdown
115
+ const logHtml = logRaw ? await transformLogToTimeline(logRaw) : '';
132
116
  const { header: postHeader, content: postMainContent } = postTemplate({
133
117
  ...data,
134
118
  title: data.title ?? data.slug,
135
119
  harness: data.harness ?? '',
136
120
  content: storyHtml,
137
121
  ticketHtml: ticketRaw ? await mdToHtmlWithImagePathRewrite(ticketRaw) : '',
138
- logHtml: logRaw ? await mdToHtmlWithImagePathRewrite(logRaw) : '',
122
+ logHtml,
139
123
  commits,
140
124
  version: ctx.version,
141
125
  jiraUrl,
126
+ repositoryUrl: ctx.config.repositoryUrl,
142
127
  prevEntry: prev ? { slug: prev.slug, title: prev.title ?? '', ticket: prev.ticket } : null,
143
128
  nextEntry: next ? { slug: next.slug, title: next.title ?? '', ticket: next.ticket } : null,
144
129
  });
@@ -18,3 +18,7 @@ export declare function copyReadmeImages(projectRoot: string, outputDir: string,
18
18
  * Process the About content and return rendered HTML.
19
19
  */
20
20
  export declare function processAboutContent(aboutContent: string): Promise<string>;
21
+ /**
22
+ * Process the Welcome content and return rendered HTML.
23
+ */
24
+ export declare function processWelcomeContent(): Promise<string>;
@@ -5,7 +5,7 @@
5
5
  import fs from 'fs-extra';
6
6
  import { join } from 'node:path';
7
7
  import { warning } from './theme.js';
8
- import { URL } from 'node:url';
8
+ import { URL, fileURLToPath } from 'node:url';
9
9
  import { mdToHtmlWithImages, copyImages } from './image-helpers.js';
10
10
  import { getStyles } from './styles.js';
11
11
  import { getClientScript } from './logbook-client.js';
@@ -55,3 +55,20 @@ export async function copyReadmeImages(projectRoot, outputDir, imagePaths) {
55
55
  export async function processAboutContent(aboutContent) {
56
56
  return await mdToHtml(aboutContent);
57
57
  }
58
+ /**
59
+ * Process the Welcome content and return rendered HTML.
60
+ */
61
+ export async function processWelcomeContent() {
62
+ try {
63
+ const welcomeUrl = new URL('../templates/WELCOME.md', import.meta.url);
64
+ const welcomePath = fileURLToPath(welcomeUrl);
65
+ if (await fs.pathExists(welcomePath)) {
66
+ const welcomeContent = await fs.readFile(welcomePath, 'utf8');
67
+ return await mdToHtml(welcomeContent);
68
+ }
69
+ }
70
+ catch (error) {
71
+ console.warn(warning(`Warning: Failed to read WELCOME.md: ${error instanceof Error ? error.message : error}`));
72
+ }
73
+ return '';
74
+ }
@@ -6,6 +6,7 @@ export interface LogbookConfig {
6
6
  primaryColor: string;
7
7
  jiraBaseUrl?: string;
8
8
  jiraPrefix?: string;
9
+ repositoryUrl?: string;
9
10
  tags?: {
10
11
  allowed: string[];
11
12
  };
@@ -1,5 +1,6 @@
1
1
  import { readFileSync, existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { getGitRemoteUrl, parseRemoteUrlToWebUrl } from './git-helpers.js';
3
4
  export function getWorkspaces(cwd = process.cwd()) {
4
5
  const pkgPath = join(cwd, 'package.json');
5
6
  if (!existsSync(pkgPath))
@@ -26,18 +27,21 @@ export const DEFAULT_CONFIG = {
26
27
  };
27
28
  export function getConfig(cwd = process.cwd()) {
28
29
  const configPath = join(cwd, '.project-logbook');
30
+ let config = { ...DEFAULT_CONFIG };
29
31
  if (existsSync(configPath)) {
30
32
  try {
31
33
  const userConfig = JSON.parse(readFileSync(configPath, 'utf8'));
32
- return { ...DEFAULT_CONFIG, ...userConfig };
34
+ config = { ...DEFAULT_CONFIG, ...userConfig };
33
35
  }
34
36
  catch (err) {
35
37
  const errorMessage = err instanceof Error ? err.message : String(err);
36
38
  console.warn(`Warning: Failed to parse .project-logbook config — using defaults. (${errorMessage})`);
37
- return DEFAULT_CONFIG;
38
39
  }
39
40
  }
40
- return DEFAULT_CONFIG;
41
+ if (!config.repositoryUrl) {
42
+ config.repositoryUrl = parseRemoteUrlToWebUrl(getGitRemoteUrl(cwd));
43
+ }
44
+ return config;
41
45
  }
42
46
  /**
43
47
  * Get the absolute path to the logbook directory.
@@ -1,4 +1,16 @@
1
1
  import type { GitCommit } from './template-types.js';
2
+ /**
3
+ * Clean the repository URL by removing trailing slashes and .git extensions.
4
+ */
5
+ export declare function cleanRepositoryUrl(url: string): string;
6
+ /**
7
+ * Generate a URL to view a specific commit in a web interface (GitHub, GitLab, Bitbucket, etc.).
8
+ */
9
+ export declare function getCommitUrl(repositoryUrl: string | undefined, sha: string): string | undefined;
10
+ /**
11
+ * Generate a URL to view a specific tag in a web interface (GitHub, GitLab, Bitbucket, etc.).
12
+ */
13
+ export declare function getTagUrl(repositoryUrl: string | undefined, tag: string): string | undefined;
2
14
  /**
3
15
  * Parse the raw output of `git log --pretty=format:"%H|%s|%aI"` into GitCommit objects.
4
16
  * Pure function (no I/O) — unit-testable without spawning git.
@@ -22,6 +34,15 @@ export declare function getGitTags(): Promise<{
22
34
  name: string;
23
35
  timestamp: string;
24
36
  }[]>;
37
+ /**
38
+ * Get the git remote URL for origin.
39
+ * Synchronous execution — safe to use during synchronous config parsing.
40
+ */
41
+ export declare function getGitRemoteUrl(cwd?: string): string | undefined;
42
+ /**
43
+ * Parse a git remote URL (SSH or HTTPS) into a clean web/HTTP(S) repository homepage URL.
44
+ */
45
+ export declare function parseRemoteUrlToWebUrl(remoteUrl: string | undefined): string | undefined;
25
46
  /**
26
47
  * Synchronous branch detection — used in session resolution (not the build pipeline).
27
48
  * Kept synchronous intentionally; simple-git is used for the heavier async build operations.
@@ -1,5 +1,55 @@
1
1
  import { execSync } from 'node:child_process';
2
2
  import { simpleGit } from 'simple-git';
3
+ /**
4
+ * Clean the repository URL by removing trailing slashes and .git extensions.
5
+ */
6
+ export function cleanRepositoryUrl(url) {
7
+ let cleaned = url.trim();
8
+ if (cleaned.endsWith('/')) {
9
+ cleaned = cleaned.slice(0, -1);
10
+ }
11
+ if (cleaned.endsWith('.git')) {
12
+ cleaned = cleaned.slice(0, -4);
13
+ }
14
+ if (cleaned.endsWith('/')) {
15
+ cleaned = cleaned.slice(0, -1);
16
+ }
17
+ return cleaned;
18
+ }
19
+ /**
20
+ * Generate a URL to view a specific commit in a web interface (GitHub, GitLab, Bitbucket, etc.).
21
+ */
22
+ export function getCommitUrl(repositoryUrl, sha) {
23
+ if (!repositoryUrl)
24
+ return undefined;
25
+ const baseUrl = cleanRepositoryUrl(repositoryUrl);
26
+ const lowerUrl = baseUrl.toLowerCase();
27
+ if (lowerUrl.includes('gitlab')) {
28
+ return `${baseUrl}/-/commit/${sha}`;
29
+ }
30
+ if (lowerUrl.includes('bitbucket')) {
31
+ return `${baseUrl}/commits/${sha}`;
32
+ }
33
+ // Default to GitHub style
34
+ return `${baseUrl}/commit/${sha}`;
35
+ }
36
+ /**
37
+ * Generate a URL to view a specific tag in a web interface (GitHub, GitLab, Bitbucket, etc.).
38
+ */
39
+ export function getTagUrl(repositoryUrl, tag) {
40
+ if (!repositoryUrl)
41
+ return undefined;
42
+ const baseUrl = cleanRepositoryUrl(repositoryUrl);
43
+ const lowerUrl = baseUrl.toLowerCase();
44
+ if (lowerUrl.includes('gitlab')) {
45
+ return `${baseUrl}/-/tags/${tag}`;
46
+ }
47
+ if (lowerUrl.includes('bitbucket')) {
48
+ return `${baseUrl}/src/${tag}`;
49
+ }
50
+ // Default to GitHub style
51
+ return `${baseUrl}/releases/tag/${tag}`;
52
+ }
3
53
  function getGit() {
4
54
  return simpleGit(process.cwd());
5
55
  }
@@ -76,6 +126,47 @@ export async function getGitTags() {
76
126
  return [];
77
127
  }
78
128
  }
129
+ /**
130
+ * Get the git remote URL for origin.
131
+ * Synchronous execution — safe to use during synchronous config parsing.
132
+ */
133
+ export function getGitRemoteUrl(cwd = process.cwd()) {
134
+ try {
135
+ return (execSync('git remote get-url origin', {
136
+ encoding: 'utf8',
137
+ cwd,
138
+ stdio: ['ignore', 'pipe', 'ignore'],
139
+ }).trim() || undefined);
140
+ }
141
+ catch {
142
+ return undefined;
143
+ }
144
+ }
145
+ /**
146
+ * Parse a git remote URL (SSH or HTTPS) into a clean web/HTTP(S) repository homepage URL.
147
+ */
148
+ export function parseRemoteUrlToWebUrl(remoteUrl) {
149
+ if (!remoteUrl)
150
+ return undefined;
151
+ let url = remoteUrl.trim();
152
+ // If it's already an HTTP/HTTPS URL, clean and return it
153
+ if (url.startsWith('https://') || url.startsWith('http://')) {
154
+ return cleanRepositoryUrl(url);
155
+ }
156
+ // Strip ssh:// scheme and git@ user if present
157
+ if (url.startsWith('ssh://')) {
158
+ url = url.slice(6);
159
+ }
160
+ if (url.startsWith('git@')) {
161
+ url = url.slice(4);
162
+ }
163
+ // Replace first colon with slash to map SSH path to web path (e.g. host:owner/repo -> host/owner/repo)
164
+ const firstColon = url.indexOf(':');
165
+ if (firstColon !== -1) {
166
+ url = url.slice(0, firstColon) + '/' + url.slice(firstColon + 1);
167
+ }
168
+ return `https://${cleanRepositoryUrl(url)}`;
169
+ }
79
170
  /**
80
171
  * Synchronous branch detection — used in session resolution (not the build pipeline).
81
172
  * Kept synchronous intentionally; simple-git is used for the heavier async build operations.
@@ -3,7 +3,7 @@ import { join, dirname, relative, resolve } from 'node:path';
3
3
  import { warning } from './theme.js';
4
4
  import { visitHastElements } from './hast-helpers.js';
5
5
  import { isExternalUrl } from './url-helpers.js';
6
- import { createImageCollectionProcessor } from './markdown-processors.js';
6
+ import { createImageCollectionProcessor, rehypeRewriteMdLinks } from './markdown-processors.js';
7
7
  /** Regex to match image links in markdown: ![alt](path) */
8
8
  const imageLinkRegex = /!\[.*?\]\(([^)]+\.(?:png|jpe?g|gif|svg|webp|bmp|ico))\)/gi;
9
9
  /**
@@ -74,7 +74,7 @@ export const rehypeRewriteImagePaths = () => {
74
74
  */
75
75
  export async function mdToHtmlWithImages(md) {
76
76
  imageProcessor.reset();
77
- const processorWithImages = createImageCollectionProcessor(imageProcessor.rehypeCollectImages, rehypeRewriteImagePaths);
77
+ const processorWithImages = createImageCollectionProcessor(imageProcessor.rehypeCollectImages, rehypeRewriteImagePaths, rehypeRewriteMdLinks);
78
78
  const result = await processorWithImages.process(md);
79
79
  return { html: result.toString(), imagePaths: imageProcessor.getImagePaths() };
80
80
  }
@@ -1,6 +1,10 @@
1
1
  import { unified } from 'unified';
2
2
  import type { Plugin } from 'unified';
3
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>;
4
8
  /**
5
9
  * General markdown processor for project files (README.md, etc.)
6
10
  * Used for files that don't need image path rewriting
@@ -15,4 +19,4 @@ export declare function createEntryMarkdownProcessor(rehypeRewriteMdLinks: Plugi
15
19
  * Image collection processor
16
20
  * Collects all image paths during markdown-to-html conversion
17
21
  */
18
- export declare function createImageCollectionProcessor(imageCollectionPlugin: Plugin<[], Root>, rehypeRewriteImagePaths: Plugin<[], Root>): ReturnType<typeof unified.prototype.use>;
22
+ export declare function createImageCollectionProcessor(imageCollectionPlugin: Plugin<[], Root>, rehypeRewriteImagePaths: Plugin<[], Root>, rehypeRewriteMdLinks?: Plugin<[], Root>): ReturnType<typeof unified.prototype.use>;
@@ -5,6 +5,28 @@ import remarkRehype from 'remark-rehype';
5
5
  import rehypeSlug from 'rehype-slug';
6
6
  import rehypeFormat from 'rehype-format';
7
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
+ };
8
30
  /**
9
31
  * Create a markdown processor with custom rehype plugins
10
32
  *
@@ -37,6 +59,10 @@ export function createEntryMarkdownProcessor(rehypeRewriteMdLinks, rehypeRewrite
37
59
  * Image collection processor
38
60
  * Collects all image paths during markdown-to-html conversion
39
61
  */
40
- export function createImageCollectionProcessor(imageCollectionPlugin, rehypeRewriteImagePaths) {
41
- return createMarkdownProcessor([imageCollectionPlugin, rehypeRewriteImagePaths]);
62
+ export function createImageCollectionProcessor(imageCollectionPlugin, rehypeRewriteImagePaths, rehypeRewriteMdLinks) {
63
+ const plugins = [imageCollectionPlugin, rehypeRewriteImagePaths];
64
+ if (rehypeRewriteMdLinks) {
65
+ plugins.unshift(rehypeRewriteMdLinks);
66
+ }
67
+ return createMarkdownProcessor(plugins);
42
68
  }
@@ -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
+ }
@@ -2,8 +2,8 @@ import type { TimelineEntry, TimelineCommitItem, TimelineTagItem, EntryLink, Git
2
2
  export declare const html: (strings: TemplateStringsArray, ...values: unknown[]) => string;
3
3
  export declare const navLink: (entry: EntryLink, dir: "prev" | "next") => string;
4
4
  export declare const renderTagsAndWorkspaces: (tags: string[] | undefined, workspaces: string[] | undefined) => string;
5
- export declare const renderCommits: (commits: GitCommit[] | undefined) => string;
5
+ export declare const renderCommits: (commits: GitCommit[] | undefined, repositoryUrl?: string) => string;
6
6
  export declare const renderTimelineEntryItem: (e: TimelineEntry) => string;
7
- export declare const renderTimelineCommitItem: (c: TimelineCommitItem, jiraBaseUrl?: string, jiraPrefix?: string) => string;
8
- 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;
9
9
  export declare const paginationLinks: (currentPage: number, totalPages: number) => string;