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.
- package/dist/commands/build.js +25 -2
- 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/build-steps.d.ts +4 -0
- package/dist/lib/build-steps.js +18 -1
- 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 +3 -0
- package/dist/lib/templates.d.ts +5 -2
- package/dist/lib/templates.js +24 -14
- 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/WELCOME.md +12 -0
- package/dist/templates/index.md +4 -0
- package/dist/templates/log.md +5 -0
- package/dist/templates/logbook-client.js +29 -0
- package/dist/templates/steer.txt +21 -5
- package/dist/templates/styles.css +182 -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/WELCOME.md +12 -0
- package/src/templates/index.md +4 -0
- package/src/templates/log.md +5 -0
- package/src/templates/logbook-client.js +29 -0
- package/src/templates/steer.txt +21 -5
- package/src/templates/styles.css +182 -0
package/dist/commands/build.js
CHANGED
|
@@ -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
|
}
|
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
|
});
|
|
@@ -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>;
|
package/dist/lib/build-steps.js
CHANGED
|
@@ -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
|
+
}
|
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;
|