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.
- package/dist/commands/build.js +22 -1
- package/dist/commands/lint.js +18 -0
- package/dist/commands/status.js +23 -7
- package/dist/lib/build-helpers.js +7 -22
- package/dist/lib/config.d.ts +1 -0
- package/dist/lib/config.js +7 -3
- package/dist/lib/git-helpers.d.ts +21 -0
- package/dist/lib/git-helpers.js +91 -0
- package/dist/lib/image-helpers.js +2 -2
- package/dist/lib/markdown-processors.d.ts +5 -1
- package/dist/lib/markdown-processors.js +28 -2
- package/dist/lib/rss.d.ts +29 -0
- package/dist/lib/rss.js +77 -0
- package/dist/lib/template-helpers.d.ts +3 -3
- package/dist/lib/template-helpers.js +19 -6
- package/dist/lib/template-types.d.ts +2 -0
- package/dist/lib/templates.d.ts +5 -2
- package/dist/lib/templates.js +10 -7
- package/dist/linters/index.js +2 -0
- package/dist/linters/technical-log.d.ts +7 -0
- package/dist/linters/technical-log.js +72 -0
- package/dist/templates/index.md +4 -0
- package/dist/templates/log.md +5 -0
- package/dist/templates/steer.txt +21 -5
- package/dist/templates/styles.css +116 -0
- package/dist/utils/date.d.ts +7 -0
- package/dist/utils/date.js +12 -0
- package/dist/utils/log-timeline.d.ts +69 -0
- package/dist/utils/log-timeline.js +218 -0
- package/package.json +3 -1
- package/src/templates/index.md +4 -0
- package/src/templates/log.md +5 -0
- package/src/templates/steer.txt +21 -5
- package/src/templates/styles.css +116 -0
package/dist/commands/build.js
CHANGED
|
@@ -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
|
}
|
package/dist/commands/lint.js
CHANGED
|
@@ -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 });
|
package/dist/commands/status.js
CHANGED
|
@@ -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
|
|
75
|
+
// Print Active Ticket section
|
|
74
76
|
console.log(`\n${bold.underline('Logbook Status')}`);
|
|
75
|
-
console.log(`\n${bold('Active
|
|
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
|
|
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('
|
|
131
|
+
console.log(`\n${bold('Logbook stats:')}`);
|
|
116
132
|
console.log(` ${bold('Total Entries:')} ${totalCount}`);
|
|
117
|
-
console.log(` ${bold('Active
|
|
118
|
-
console.log(` ${bold('
|
|
119
|
-
console.log(` ${bold('
|
|
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 {
|
|
7
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
});
|
package/dist/lib/config.d.ts
CHANGED
package/dist/lib/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
package/dist/lib/git-helpers.js
CHANGED
|
@@ -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:  */
|
|
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
|
-
|
|
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 {};
|
package/dist/lib/rss.js
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = '';
|