project-logbook 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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();
@@ -53,7 +54,7 @@ export async function build() {
53
54
  if (!dateStart || dateStart.includes('{{') || dateStart === '[DATE_START]')
54
55
  continue;
55
56
  const dateEndValue = data.dateEnd;
56
- const dateEnd = typeof dateEndValue === 'string'
57
+ const dateEnd = typeof dateEndValue === 'string' && dateEndValue !== '[DATE_END]'
57
58
  ? dateEndValue
58
59
  : dateEndValue instanceof Date
59
60
  ? dateEndValue.toISOString()
@@ -130,8 +131,10 @@ export async function build() {
130
131
  aboutHtml,
131
132
  jiraBaseUrl: config.jiraBaseUrl,
132
133
  jiraPrefix: config.jiraPrefix,
134
+ repositoryUrl: config.repositoryUrl,
133
135
  currentPage,
134
136
  totalPages,
137
+ includeRssLink: currentPage === 1, // Only show RSS link on first page
135
138
  }); // prettier-ignore
136
139
  const buildMeta = generateBuildMeta(version, buildTime);
137
140
  const timelineHtml = layout({
@@ -141,6 +144,7 @@ export async function build() {
141
144
  buildMeta,
142
145
  header: timelineHeader,
143
146
  content: timelineMainContent,
147
+ includeRssLink: currentPage === 1, // Only show RSS link on first page
144
148
  });
145
149
  const fileName = currentPage === 1 ? 'index.html' : `index-${currentPage}.html`;
146
150
  await fs.writeFile(join(outputDir, fileName), timelineHtml);
@@ -150,5 +154,22 @@ export async function build() {
150
154
  version,
151
155
  buildTime,
152
156
  });
157
+ // Generate RSS feed
158
+ const rssConfig = {
159
+ title: `${config.projectName} - Recent Updates`,
160
+ description: `Latest logbook entries from ${config.projectName}`,
161
+ language: 'en',
162
+ feed_url: `${config.projectName.toLowerCase().replace(/\s+/g, '-')}/rss.xml`,
163
+ site_url: config.projectName,
164
+ generator: `Project Logbook CLI v${version}`,
165
+ };
166
+ const rssXml = generateRssFeed(timelineEntries, rssConfig);
167
+ // Basic validation
168
+ if (!validateRssXml(rssXml)) {
169
+ console.error(error('Error: Generated RSS feed failed basic validation.'));
170
+ return;
171
+ }
172
+ await fs.writeFile(join(outputDir, 'rss.xml'), rssXml);
173
+ console.log(neutral(` Generated RSS feed with ${Math.min(50, timelineEntries.length)} entries`));
153
174
  console.log(success(`\nSuccessfully built logbook to ${config.outputDir}/`));
154
175
  }
@@ -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
  });
@@ -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;
@@ -1,6 +1,7 @@
1
1
  import { linkJiraIds } from './jira-helpers.js';
2
2
  import { formatAbsoluteDate } from '../utils/date.js';
3
3
  import { escapeHtml } from './html-escape.js';
4
+ import { getCommitUrl, getTagUrl } from './git-helpers.js';
4
5
  import { ATTR_ENTRY_SLUG, ATTR_DATA_DATE, CLASS_COMMIT_SHA, CLASS_COMMIT_TIME, CLASS_COMMIT_MESSAGE, } from './html-attributes.js';
5
6
  export const html = (strings, ...values) => {
6
7
  return strings.reduce((acc, str, i) => acc + str + (values[i] ?? ''), '');
@@ -27,7 +28,7 @@ export const renderTagsAndWorkspaces = (tags, workspaces) => {
27
28
  return html `<div class="tags-wrapper">${tagsHtml}${wsHtml}</div>`;
28
29
  };
29
30
  // Renders a compact list of git commits to embed in the Technical Log tab.
30
- export const renderCommits = (commits) => {
31
+ export const renderCommits = (commits, repositoryUrl) => {
31
32
  if (!commits || commits.length === 0)
32
33
  return '';
33
34
  const rows = commits
@@ -35,7 +36,11 @@ export const renderCommits = (commits) => {
35
36
  const formatted = formatAbsoluteDate(c.timestamp);
36
37
  const sha = escapeHtml(c.sha);
37
38
  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
+ const commitUrl = getCommitUrl(repositoryUrl, c.sha);
40
+ const shaHtml = commitUrl
41
+ ? `<a href="${escapeHtml(commitUrl)}" class="${CLASS_COMMIT_SHA}" target="_blank" rel="noopener noreferrer">${sha}</a>`
42
+ : `<span class="${CLASS_COMMIT_SHA}">${sha}</span>`;
43
+ return `<div class="commit-item">${shaHtml}<span class="${CLASS_COMMIT_MESSAGE}">${message}</span><span class="${CLASS_COMMIT_TIME}" title="${c.timestamp}" ${ATTR_DATA_DATE}="${c.timestamp}">${formatted}</span></div>`;
39
44
  })
40
45
  .join('');
41
46
  return `<div class="commit-list"><h3 class="commit-list-heading">Git Commits</h3>${rows}</div>`;
@@ -64,16 +69,24 @@ export const renderTimelineEntryItem = (e) => {
64
69
  >
65
70
  </div>`;
66
71
  };
67
- export const renderTimelineCommitItem = (c, jiraBaseUrl, jiraPrefix) => {
72
+ export const renderTimelineCommitItem = (c, jiraBaseUrl, jiraPrefix, repositoryUrl) => {
68
73
  const message = jiraBaseUrl && jiraPrefix ? linkJiraIds(c.message, jiraBaseUrl, jiraPrefix) : escapeHtml(c.message);
69
74
  const formatted = formatAbsoluteDate(c.timestamp);
70
75
  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>`;
76
+ const commitUrl = getCommitUrl(repositoryUrl, c.sha);
77
+ const shaHtml = commitUrl
78
+ ? `<a href="${escapeHtml(commitUrl)}" class="${CLASS_COMMIT_SHA}" target="_blank" rel="noopener noreferrer">${sha}</a>`
79
+ : `<span class="${CLASS_COMMIT_SHA}">${sha}</span>`;
80
+ return `<div class="timeline-item timeline-item--commit"><div class="commit-chip">${shaHtml}<span class="${CLASS_COMMIT_MESSAGE}">${message}</span><span class="${CLASS_COMMIT_TIME}" title="${c.timestamp}" ${ATTR_DATA_DATE}="${c.timestamp}">${formatted}</span></div></div>`;
72
81
  };
73
- export const renderTimelineTagItem = (t) => {
82
+ export const renderTimelineTagItem = (t, repositoryUrl) => {
74
83
  const formatted = formatAbsoluteDate(t.timestamp);
75
84
  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>`;
85
+ const tagUrl = getTagUrl(repositoryUrl, t.name);
86
+ const tagHtml = tagUrl
87
+ ? `<a href="${escapeHtml(tagUrl)}" class="tag-chip-name" target="_blank" rel="noopener noreferrer">${name}</a>`
88
+ : `<span class="tag-chip-name">${name}</span>`;
89
+ return `<div class="timeline-item timeline-item--tag"><div class="tag-chip"><span class="tag-chip-icon">🏷</span>${tagHtml}<span class="tag-chip-date" title="${t.timestamp}" ${ATTR_DATA_DATE}="${t.timestamp}">${formatted}</span></div></div>`;
77
90
  };
78
91
  export const paginationLinks = (currentPage, totalPages) => {
79
92
  let links = '';