project-logbook 0.3.3 → 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 +54 -76
- package/dist/commands/init.js +11 -11
- package/dist/commands/lint.js +27 -10
- package/dist/commands/list.js +18 -18
- package/dist/commands/log.js +5 -5
- package/dist/commands/new.js +19 -20
- package/dist/commands/preview.js +9 -8
- package/dist/commands/release.js +6 -6
- package/dist/commands/start.js +26 -21
- package/dist/commands/status.d.ts +1 -0
- package/dist/commands/status.js +138 -0
- package/dist/commands/steer.js +9 -9
- package/dist/commands/upgrade.js +5 -5
- package/dist/index.js +9 -8
- package/dist/lib/build-helpers.js +30 -67
- package/dist/lib/build-steps.d.ts +20 -0
- package/dist/lib/build-steps.js +57 -0
- package/dist/lib/config.d.ts +17 -0
- package/dist/lib/config.js +27 -3
- package/dist/lib/entry-id.d.ts +22 -0
- package/dist/lib/entry-id.js +26 -0
- package/dist/lib/entry-paths.d.ts +23 -0
- package/dist/lib/entry-paths.js +55 -0
- package/dist/lib/git-helpers.d.ts +32 -1
- package/dist/lib/git-helpers.js +119 -26
- package/dist/lib/hast-helpers.d.ts +10 -0
- package/dist/lib/hast-helpers.js +22 -0
- package/dist/lib/html-attributes.d.ts +17 -0
- package/dist/lib/html-attributes.js +17 -0
- package/dist/lib/html-escape.d.ts +16 -0
- package/dist/lib/html-escape.js +38 -0
- package/dist/lib/image-helpers.js +26 -33
- package/dist/lib/lint-runner.js +5 -5
- package/dist/lib/markdown-processors.d.ts +22 -0
- package/dist/lib/markdown-processors.js +68 -0
- package/dist/lib/package-version.d.ts +5 -0
- package/dist/lib/package-version.js +16 -0
- package/dist/lib/rss.d.ts +29 -0
- package/dist/lib/rss.js +77 -0
- package/dist/lib/styles.js +5 -2
- package/dist/lib/template-helpers.d.ts +4 -3
- package/dist/lib/template-helpers.js +51 -27
- package/dist/lib/template-types.d.ts +2 -2
- package/dist/lib/templates.d.ts +12 -3
- package/dist/lib/templates.js +59 -52
- package/dist/lib/theme.d.ts +37 -0
- package/dist/lib/theme.js +50 -0
- package/dist/lib/url-helpers.d.ts +13 -0
- package/dist/lib/url-helpers.js +27 -0
- package/dist/linters/diff-to-narrative.d.ts +6 -0
- package/dist/linters/diff-to-narrative.js +114 -0
- package/dist/linters/index.js +4 -0
- package/dist/linters/technical-log.d.ts +7 -0
- package/dist/linters/technical-log.js +72 -0
- package/dist/templates/CONTRIBUTING.md +12 -3
- package/dist/templates/index.md +10 -6
- package/dist/templates/log.md +5 -0
- package/dist/templates/logbook-client.js +42 -16
- package/dist/templates/steer.txt +21 -5
- package/dist/templates/styles.css +121 -0
- package/dist/utils/date.d.ts +30 -1
- package/dist/utils/date.js +68 -15
- package/dist/utils/frontmatter.d.ts +26 -0
- package/dist/utils/frontmatter.js +37 -0
- package/dist/utils/fs.d.ts +13 -0
- package/dist/utils/fs.js +23 -0
- package/dist/utils/log-timeline.d.ts +69 -0
- package/dist/utils/log-timeline.js +218 -0
- package/package.json +4 -2
- package/src/templates/CONTRIBUTING.md +12 -3
- package/src/templates/index.md +10 -6
- package/src/templates/log.md +5 -0
- package/src/templates/logbook-client.js +42 -16
- package/src/templates/steer.txt +21 -5
- package/src/templates/styles.css +121 -0
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,16 +27,39 @@ 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;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Get the absolute path to the logbook directory.
|
|
48
|
+
* Convenience function to avoid repeating: join(process.cwd(), config.logbookDir)
|
|
49
|
+
* @param config - The logbook config
|
|
50
|
+
* @param cwd - Optional working directory (defaults to process.cwd())
|
|
51
|
+
* @returns Absolute path to the logbook directory
|
|
52
|
+
*/
|
|
53
|
+
export function getLogbookDirPath(config, cwd = process.cwd()) {
|
|
54
|
+
return join(cwd, config.logbookDir);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Get the absolute path to the output directory.
|
|
58
|
+
* Convenience function to avoid repeating: join(process.cwd(), config.outputDir)
|
|
59
|
+
* @param config - The logbook config
|
|
60
|
+
* @param cwd - Optional working directory (defaults to process.cwd())
|
|
61
|
+
* @returns Absolute path to the output directory
|
|
62
|
+
*/
|
|
63
|
+
export function getOutputDirPath(config, cwd = process.cwd()) {
|
|
64
|
+
return join(cwd, config.outputDir);
|
|
41
65
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entry ID utilities for handling slug/ticket pairs.
|
|
3
|
+
* Normalizes the relationship between entry slug and optional ticket ID.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Get the display ID for an entry.
|
|
7
|
+
* Falls back to slug if ticket is not available.
|
|
8
|
+
* Used when displaying entries in lists, timelines, etc.
|
|
9
|
+
* @param ticket - Optional ticket ID (e.g., "LB-43", "PROJ-100")
|
|
10
|
+
* @param slug - Fallback slug (e.g., "LB-43-my-feature")
|
|
11
|
+
* @returns The ticket if available, otherwise the slug
|
|
12
|
+
*/
|
|
13
|
+
export declare function getEntryDisplayId(ticket: string | undefined, slug: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* Get the title/display name for an entry.
|
|
16
|
+
* Falls back to slug if title is not available.
|
|
17
|
+
* Used when displaying entries without a proper title.
|
|
18
|
+
* @param title - Optional title (e.g., "Implement feature X")
|
|
19
|
+
* @param slug - Fallback slug (e.g., "LB-43-my-feature")
|
|
20
|
+
* @returns The title if available, otherwise the slug
|
|
21
|
+
*/
|
|
22
|
+
export declare function getEntryDisplayTitle(title: string | undefined, slug: string): string;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entry ID utilities for handling slug/ticket pairs.
|
|
3
|
+
* Normalizes the relationship between entry slug and optional ticket ID.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Get the display ID for an entry.
|
|
7
|
+
* Falls back to slug if ticket is not available.
|
|
8
|
+
* Used when displaying entries in lists, timelines, etc.
|
|
9
|
+
* @param ticket - Optional ticket ID (e.g., "LB-43", "PROJ-100")
|
|
10
|
+
* @param slug - Fallback slug (e.g., "LB-43-my-feature")
|
|
11
|
+
* @returns The ticket if available, otherwise the slug
|
|
12
|
+
*/
|
|
13
|
+
export function getEntryDisplayId(ticket, slug) {
|
|
14
|
+
return ticket || slug;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Get the title/display name for an entry.
|
|
18
|
+
* Falls back to slug if title is not available.
|
|
19
|
+
* Used when displaying entries without a proper title.
|
|
20
|
+
* @param title - Optional title (e.g., "Implement feature X")
|
|
21
|
+
* @param slug - Fallback slug (e.g., "LB-43-my-feature")
|
|
22
|
+
* @returns The title if available, otherwise the slug
|
|
23
|
+
*/
|
|
24
|
+
export function getEntryDisplayTitle(title, slug) {
|
|
25
|
+
return title || slug;
|
|
26
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encapsulates knowledge of logbook entry folder structure.
|
|
3
|
+
* Provides consistent path construction for entry-related files.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Get the base directory path for a logbook entry.
|
|
7
|
+
* @param logbookDir - The root logbook directory (e.g., "/project/logbook")
|
|
8
|
+
* @param entrySlug - The entry's slug (e.g., "LB-43-my-feature")
|
|
9
|
+
* @returns Path to the entry folder (e.g., "/project/logbook/LB-43-my-feature")
|
|
10
|
+
*/
|
|
11
|
+
export declare function getEntryPath(logbookDir: string, entrySlug: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Get all standard entry file paths at once.
|
|
14
|
+
* Useful when you need to access index, ticket, and log files.
|
|
15
|
+
* @param logbookDir - The root logbook directory
|
|
16
|
+
* @param entrySlug - The entry's slug
|
|
17
|
+
* @returns Object with paths to index.md, ticket.md, and log.md
|
|
18
|
+
*/
|
|
19
|
+
export declare function getEntryFilePaths(logbookDir: string, entrySlug: string): {
|
|
20
|
+
index: string;
|
|
21
|
+
ticket: string;
|
|
22
|
+
log: string;
|
|
23
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
/**
|
|
3
|
+
* Encapsulates knowledge of logbook entry folder structure.
|
|
4
|
+
* Provides consistent path construction for entry-related files.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Get the base directory path for a logbook entry.
|
|
8
|
+
* @param logbookDir - The root logbook directory (e.g., "/project/logbook")
|
|
9
|
+
* @param entrySlug - The entry's slug (e.g., "LB-43-my-feature")
|
|
10
|
+
* @returns Path to the entry folder (e.g., "/project/logbook/LB-43-my-feature")
|
|
11
|
+
*/
|
|
12
|
+
export function getEntryPath(logbookDir, entrySlug) {
|
|
13
|
+
return join(logbookDir, entrySlug);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Get the path to an entry's index.md file.
|
|
17
|
+
* @param logbookDir - The root logbook directory
|
|
18
|
+
* @param entrySlug - The entry's slug
|
|
19
|
+
* @returns Path to index.md
|
|
20
|
+
*/
|
|
21
|
+
function getEntryIndexPath(logbookDir, entrySlug) {
|
|
22
|
+
return join(getEntryPath(logbookDir, entrySlug), 'index.md');
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Get the path to an entry's ticket.md file.
|
|
26
|
+
* @param logbookDir - The root logbook directory
|
|
27
|
+
* @param entrySlug - The entry's slug
|
|
28
|
+
* @returns Path to ticket.md
|
|
29
|
+
*/
|
|
30
|
+
function getEntryTicketPath(logbookDir, entrySlug) {
|
|
31
|
+
return join(getEntryPath(logbookDir, entrySlug), 'ticket.md');
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Get the path to an entry's log.md file.
|
|
35
|
+
* @param logbookDir - The root logbook directory
|
|
36
|
+
* @param entrySlug - The entry's slug
|
|
37
|
+
* @returns Path to log.md
|
|
38
|
+
*/
|
|
39
|
+
function getEntryLogPath(logbookDir, entrySlug) {
|
|
40
|
+
return join(getEntryPath(logbookDir, entrySlug), 'log.md');
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Get all standard entry file paths at once.
|
|
44
|
+
* Useful when you need to access index, ticket, and log files.
|
|
45
|
+
* @param logbookDir - The root logbook directory
|
|
46
|
+
* @param entrySlug - The entry's slug
|
|
47
|
+
* @returns Object with paths to index.md, ticket.md, and log.md
|
|
48
|
+
*/
|
|
49
|
+
export function getEntryFilePaths(logbookDir, entrySlug) {
|
|
50
|
+
return {
|
|
51
|
+
index: getEntryIndexPath(logbookDir, entrySlug),
|
|
52
|
+
ticket: getEntryTicketPath(logbookDir, entrySlug),
|
|
53
|
+
log: getEntryLogPath(logbookDir, entrySlug),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -1,12 +1,26 @@
|
|
|
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.
|
|
5
17
|
*
|
|
6
18
|
* Uses indexOf/lastIndexOf to delimit fields so commit messages containing `|`
|
|
7
19
|
* are preserved correctly instead of being silently truncated.
|
|
20
|
+
*
|
|
21
|
+
* Note: Relative time calculation is done client-side via JavaScript, not here.
|
|
8
22
|
*/
|
|
9
|
-
export declare function parseGitLogOutput(raw: string
|
|
23
|
+
export declare function parseGitLogOutput(raw: string): GitCommit[];
|
|
10
24
|
/**
|
|
11
25
|
* Fetch git commits that touched `dir`, up to `maxCount`.
|
|
12
26
|
* Returns an empty array if git is unavailable or the directory is not a repo.
|
|
@@ -20,8 +34,25 @@ export declare function getGitTags(): Promise<{
|
|
|
20
34
|
name: string;
|
|
21
35
|
timestamp: string;
|
|
22
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;
|
|
23
46
|
/**
|
|
24
47
|
* Synchronous branch detection — used in session resolution (not the build pipeline).
|
|
25
48
|
* Kept synchronous intentionally; simple-git is used for the heavier async build operations.
|
|
26
49
|
*/
|
|
27
50
|
export declare function getCurrentBranch(cwd?: string): string | undefined;
|
|
51
|
+
/**
|
|
52
|
+
* Calculate the changed LOC (insertions + deletions) for the active branch or main working tree.
|
|
53
|
+
*/
|
|
54
|
+
export declare function getChangedLOC(logbookDir: string, cwd?: string): Promise<{
|
|
55
|
+
total: number;
|
|
56
|
+
insertions: number;
|
|
57
|
+
deletions: number;
|
|
58
|
+
}>;
|
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
|
}
|
|
@@ -9,8 +59,10 @@ function getGit() {
|
|
|
9
59
|
*
|
|
10
60
|
* Uses indexOf/lastIndexOf to delimit fields so commit messages containing `|`
|
|
11
61
|
* are preserved correctly instead of being silently truncated.
|
|
62
|
+
*
|
|
63
|
+
* Note: Relative time calculation is done client-side via JavaScript, not here.
|
|
12
64
|
*/
|
|
13
|
-
export function parseGitLogOutput(raw
|
|
65
|
+
export function parseGitLogOutput(raw) {
|
|
14
66
|
if (!raw.trim())
|
|
15
67
|
return [];
|
|
16
68
|
return raw
|
|
@@ -26,34 +78,10 @@ export function parseGitLogOutput(raw, now = new Date()) {
|
|
|
26
78
|
const timestamp = line.slice(lastPipe + 1);
|
|
27
79
|
if (!sha || !message || !timestamp)
|
|
28
80
|
return null;
|
|
29
|
-
|
|
30
|
-
return { sha: sha.slice(0, 7), message, timestamp, relativeTime };
|
|
81
|
+
return { sha: sha.slice(0, 7), message, timestamp };
|
|
31
82
|
})
|
|
32
83
|
.filter((c) => c !== null);
|
|
33
84
|
}
|
|
34
|
-
function formatRelativeTime(isoTimestamp, now) {
|
|
35
|
-
const d = new Date(isoTimestamp);
|
|
36
|
-
if (isNaN(d.getTime()))
|
|
37
|
-
return isoTimestamp;
|
|
38
|
-
const diffMs = now.getTime() - d.getTime();
|
|
39
|
-
const diffSec = Math.floor(diffMs / 1000);
|
|
40
|
-
if (diffSec < 60)
|
|
41
|
-
return 'just now';
|
|
42
|
-
const diffMin = Math.floor(diffSec / 60);
|
|
43
|
-
if (diffMin < 60)
|
|
44
|
-
return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`;
|
|
45
|
-
const diffHour = Math.floor(diffMin / 60);
|
|
46
|
-
if (diffHour < 24)
|
|
47
|
-
return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`;
|
|
48
|
-
const diffDay = Math.floor(diffHour / 24);
|
|
49
|
-
if (diffDay < 30)
|
|
50
|
-
return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
|
|
51
|
-
const diffMonth = Math.floor(diffDay / 30);
|
|
52
|
-
if (diffMonth < 12)
|
|
53
|
-
return `${diffMonth} month${diffMonth === 1 ? '' : 's'} ago`;
|
|
54
|
-
const diffYear = Math.floor(diffMonth / 12);
|
|
55
|
-
return `${diffYear} year${diffYear === 1 ? '' : 's'} ago`;
|
|
56
|
-
}
|
|
57
85
|
/**
|
|
58
86
|
* Fetch git commits that touched `dir`, up to `maxCount`.
|
|
59
87
|
* Returns an empty array if git is unavailable or the directory is not a repo.
|
|
@@ -98,6 +126,47 @@ export async function getGitTags() {
|
|
|
98
126
|
return [];
|
|
99
127
|
}
|
|
100
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
|
+
}
|
|
101
170
|
/**
|
|
102
171
|
* Synchronous branch detection — used in session resolution (not the build pipeline).
|
|
103
172
|
* Kept synchronous intentionally; simple-git is used for the heavier async build operations.
|
|
@@ -114,3 +183,27 @@ export function getCurrentBranch(cwd = process.cwd()) {
|
|
|
114
183
|
return undefined;
|
|
115
184
|
}
|
|
116
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* Calculate the changed LOC (insertions + deletions) for the active branch or main working tree.
|
|
188
|
+
*/
|
|
189
|
+
export async function getChangedLOC(logbookDir, cwd = process.cwd()) {
|
|
190
|
+
try {
|
|
191
|
+
const branchName = getCurrentBranch(cwd) || 'main';
|
|
192
|
+
const git = simpleGit(cwd);
|
|
193
|
+
let summary;
|
|
194
|
+
if (branchName === 'main') {
|
|
195
|
+
summary = await git.diffSummary(['HEAD', '--', '.', `:!${logbookDir}`]);
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
summary = await git.diffSummary(['main...HEAD', '--', '.', `:!${logbookDir}`]);
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
total: summary.insertions + summary.deletions,
|
|
202
|
+
insertions: summary.insertions,
|
|
203
|
+
deletions: summary.deletions,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return { total: 0, insertions: 0, deletions: 0 };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Root, Element } from 'hast';
|
|
2
|
+
/**
|
|
3
|
+
* Generic HAST tree visitor that traverses depth-first and applies a callback
|
|
4
|
+
* to elements matching the specified tagName(s).
|
|
5
|
+
*
|
|
6
|
+
* @param node - Root or Element node to traverse
|
|
7
|
+
* @param tagNames - Single tag name or array of tag names to match
|
|
8
|
+
* @param visitor - Callback applied to each matching element
|
|
9
|
+
*/
|
|
10
|
+
export declare function visitHastElements(node: Root | Element, tagNames: string | string[], visitor: (node: Element) => void): void;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic HAST tree visitor that traverses depth-first and applies a callback
|
|
3
|
+
* to elements matching the specified tagName(s).
|
|
4
|
+
*
|
|
5
|
+
* @param node - Root or Element node to traverse
|
|
6
|
+
* @param tagNames - Single tag name or array of tag names to match
|
|
7
|
+
* @param visitor - Callback applied to each matching element
|
|
8
|
+
*/
|
|
9
|
+
export function visitHastElements(node, tagNames, visitor) {
|
|
10
|
+
const tagNamesSet = new Set(Array.isArray(tagNames) ? tagNames : [tagNames]);
|
|
11
|
+
function traverse(n) {
|
|
12
|
+
for (const child of n.children) {
|
|
13
|
+
if (child.type === 'element') {
|
|
14
|
+
if (tagNamesSet.has(child.tagName)) {
|
|
15
|
+
visitor(child);
|
|
16
|
+
}
|
|
17
|
+
traverse(child);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
traverse(node);
|
|
22
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML attribute name constants.
|
|
3
|
+
* Centralized definitions for data attributes and class names used in templates
|
|
4
|
+
* to maintain consistency between template generation and JavaScript consumers.
|
|
5
|
+
*/
|
|
6
|
+
/** data-page-slug: Identifies the current page/entry slug in the document body */
|
|
7
|
+
export declare const ATTR_PAGE_SLUG = "data-page-slug";
|
|
8
|
+
/** data-entry-slug: Identifies a timeline entry's slug */
|
|
9
|
+
export declare const ATTR_ENTRY_SLUG = "data-entry-slug";
|
|
10
|
+
/** data-date: Stores a timestamp for client-side date formatting */
|
|
11
|
+
export declare const ATTR_DATA_DATE = "data-date";
|
|
12
|
+
/** Class name for commit SHA display */
|
|
13
|
+
export declare const CLASS_COMMIT_SHA = "commit-sha";
|
|
14
|
+
/** Class name for commit timestamp display */
|
|
15
|
+
export declare const CLASS_COMMIT_TIME = "commit-time";
|
|
16
|
+
/** Class name for commit message display */
|
|
17
|
+
export declare const CLASS_COMMIT_MESSAGE = "commit-message";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML attribute name constants.
|
|
3
|
+
* Centralized definitions for data attributes and class names used in templates
|
|
4
|
+
* to maintain consistency between template generation and JavaScript consumers.
|
|
5
|
+
*/
|
|
6
|
+
/** data-page-slug: Identifies the current page/entry slug in the document body */
|
|
7
|
+
export const ATTR_PAGE_SLUG = 'data-page-slug';
|
|
8
|
+
/** data-entry-slug: Identifies a timeline entry's slug */
|
|
9
|
+
export const ATTR_ENTRY_SLUG = 'data-entry-slug';
|
|
10
|
+
/** data-date: Stores a timestamp for client-side date formatting */
|
|
11
|
+
export const ATTR_DATA_DATE = 'data-date';
|
|
12
|
+
/** Class name for commit SHA display */
|
|
13
|
+
export const CLASS_COMMIT_SHA = 'commit-sha';
|
|
14
|
+
/** Class name for commit timestamp display */
|
|
15
|
+
export const CLASS_COMMIT_TIME = 'commit-time';
|
|
16
|
+
/** Class name for commit message display */
|
|
17
|
+
export const CLASS_COMMIT_MESSAGE = 'commit-message';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML entity escaping utility.
|
|
3
|
+
* Escapes characters that have special meaning in HTML to prevent XSS attacks.
|
|
4
|
+
*/
|
|
5
|
+
export declare function escapeHtml(str: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Validates a hex color string.
|
|
8
|
+
* Accepts 3-digit (#abc), 6-digit (#abcdef), or 8-digit (#abcdef00) hex colors.
|
|
9
|
+
* Returns true if valid, false otherwise.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isValidHexColor(color: string): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Validates that a URL has a safe scheme (http or https).
|
|
14
|
+
* Returns the URL if valid, undefined if invalid.
|
|
15
|
+
*/
|
|
16
|
+
export declare function validateUrlScheme(url: string): string | undefined;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML entity escaping utility.
|
|
3
|
+
* Escapes characters that have special meaning in HTML to prevent XSS attacks.
|
|
4
|
+
*/
|
|
5
|
+
export function escapeHtml(str) {
|
|
6
|
+
if (!str)
|
|
7
|
+
return '';
|
|
8
|
+
return str
|
|
9
|
+
.replace(/&/g, '&')
|
|
10
|
+
.replace(/</g, '<')
|
|
11
|
+
.replace(/>/g, '>')
|
|
12
|
+
.replace(/"/g, '"')
|
|
13
|
+
.replace(/'/g, ''');
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Validates a hex color string.
|
|
17
|
+
* Accepts 3-digit (#abc), 6-digit (#abcdef), or 8-digit (#abcdef00) hex colors.
|
|
18
|
+
* Returns true if valid, false otherwise.
|
|
19
|
+
*/
|
|
20
|
+
export function isValidHexColor(color) {
|
|
21
|
+
return /^#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?(?:[0-9a-fA-F]{2})?$/.test(color);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Validates that a URL has a safe scheme (http or https).
|
|
25
|
+
* Returns the URL if valid, undefined if invalid.
|
|
26
|
+
*/
|
|
27
|
+
export function validateUrlScheme(url) {
|
|
28
|
+
try {
|
|
29
|
+
const parsed = new URL(url);
|
|
30
|
+
if (parsed.protocol === 'https:' || parsed.protocol === 'http:') {
|
|
31
|
+
return url;
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
|
-
import { join, dirname, relative } from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import rehypeSlug from 'rehype-slug';
|
|
8
|
-
import rehypeFormat from 'rehype-format';
|
|
9
|
-
import rehypeStringify from 'rehype-stringify';
|
|
10
|
-
import chalk from 'chalk';
|
|
2
|
+
import { join, dirname, relative, resolve } from 'node:path';
|
|
3
|
+
import { warning } from './theme.js';
|
|
4
|
+
import { visitHastElements } from './hast-helpers.js';
|
|
5
|
+
import { isExternalUrl } from './url-helpers.js';
|
|
6
|
+
import { createImageCollectionProcessor, rehypeRewriteMdLinks } from './markdown-processors.js';
|
|
11
7
|
/** Regex to match image links in markdown:  */
|
|
12
8
|
const imageLinkRegex = /!\[.*?\]\(([^)]+\.(?:png|jpe?g|gif|svg|webp|bmp|ico))\)/gi;
|
|
13
9
|
/**
|
|
@@ -34,12 +30,12 @@ class ImageProcessor {
|
|
|
34
30
|
/** Rehype plugin: collect image paths from markdown */
|
|
35
31
|
rehypeCollectImages = () => {
|
|
36
32
|
return (tree) => {
|
|
37
|
-
|
|
33
|
+
visitHastElements(tree, 'img', (node) => {
|
|
38
34
|
const src = node.properties?.src;
|
|
39
35
|
if (typeof src !== 'string')
|
|
40
36
|
return;
|
|
41
37
|
// Skip external URLs and data URIs
|
|
42
|
-
if (
|
|
38
|
+
if (isExternalUrl(src))
|
|
43
39
|
return;
|
|
44
40
|
if (src.startsWith('data:'))
|
|
45
41
|
return;
|
|
@@ -49,24 +45,15 @@ class ImageProcessor {
|
|
|
49
45
|
};
|
|
50
46
|
}
|
|
51
47
|
const imageProcessor = new ImageProcessor();
|
|
52
|
-
function visitImages(node, visitor) {
|
|
53
|
-
for (const child of node.children) {
|
|
54
|
-
if (child.type === 'element') {
|
|
55
|
-
if (child.tagName === 'img')
|
|
56
|
-
visitor(child);
|
|
57
|
-
visitImages(child, visitor);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
48
|
/** Rehype plugin: rewrite relative image paths to use /images/ subfolder */
|
|
62
49
|
export const rehypeRewriteImagePaths = () => {
|
|
63
50
|
return (tree) => {
|
|
64
|
-
|
|
51
|
+
visitHastElements(tree, 'img', (node) => {
|
|
65
52
|
const src = node.properties?.src;
|
|
66
53
|
if (typeof src !== 'string')
|
|
67
54
|
return;
|
|
68
55
|
// Skip external URLs and data URIs
|
|
69
|
-
if (
|
|
56
|
+
if (isExternalUrl(src))
|
|
70
57
|
return;
|
|
71
58
|
if (src.startsWith('data:'))
|
|
72
59
|
return;
|
|
@@ -87,15 +74,7 @@ export const rehypeRewriteImagePaths = () => {
|
|
|
87
74
|
*/
|
|
88
75
|
export async function mdToHtmlWithImages(md) {
|
|
89
76
|
imageProcessor.reset();
|
|
90
|
-
const processorWithImages =
|
|
91
|
-
.use(remarkParse)
|
|
92
|
-
.use(remarkGfm)
|
|
93
|
-
.use(remarkRehype)
|
|
94
|
-
.use(imageProcessor.rehypeCollectImages)
|
|
95
|
-
.use(rehypeRewriteImagePaths)
|
|
96
|
-
.use(rehypeSlug)
|
|
97
|
-
.use(rehypeFormat)
|
|
98
|
-
.use(rehypeStringify);
|
|
77
|
+
const processorWithImages = createImageCollectionProcessor(imageProcessor.rehypeCollectImages, rehypeRewriteImagePaths, rehypeRewriteMdLinks);
|
|
99
78
|
const result = await processorWithImages.process(md);
|
|
100
79
|
return { html: result.toString(), imagePaths: imageProcessor.getImagePaths() };
|
|
101
80
|
}
|
|
@@ -108,6 +87,7 @@ export async function mdToHtmlWithImages(md) {
|
|
|
108
87
|
export async function copyImages(sourceDir, outputDir, imagePaths) {
|
|
109
88
|
await fs.mkdirp(outputDir);
|
|
110
89
|
const processedPaths = new Set();
|
|
90
|
+
const projectRoot = resolve(process.cwd());
|
|
111
91
|
for (const imagePath of imagePaths) {
|
|
112
92
|
// Skip if already processed
|
|
113
93
|
if (processedPaths.has(imagePath))
|
|
@@ -119,7 +99,13 @@ export async function copyImages(sourceDir, outputDir, imagePaths) {
|
|
|
119
99
|
if (imagePath.startsWith('/')) {
|
|
120
100
|
// Absolute path like /static/screenshot.jpg -> copy from project root to public/static/
|
|
121
101
|
const relativeToProjectRoot = imagePath.substring(1); // Remove leading /
|
|
122
|
-
sourcePath = join(
|
|
102
|
+
sourcePath = join(projectRoot, relativeToProjectRoot);
|
|
103
|
+
// Path traversal protection: ensure resolved path stays within project root
|
|
104
|
+
const resolvedSource = resolve(sourcePath);
|
|
105
|
+
if (!resolvedSource.startsWith(projectRoot + '/') && resolvedSource !== projectRoot) {
|
|
106
|
+
console.warn(warning(` Path traversal blocked: ${imagePath}`));
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
123
109
|
destRelPath = relativeToProjectRoot;
|
|
124
110
|
}
|
|
125
111
|
else {
|
|
@@ -130,12 +116,19 @@ export async function copyImages(sourceDir, outputDir, imagePaths) {
|
|
|
130
116
|
}
|
|
131
117
|
const fullPath = join(sourceDir, resolvedPath);
|
|
132
118
|
sourcePath = fullPath;
|
|
119
|
+
// Path traversal protection: ensure resolved path stays within source directory
|
|
120
|
+
const resolvedSource = resolve(sourcePath);
|
|
121
|
+
const resolvedSourceDir = resolve(sourceDir);
|
|
122
|
+
if (!resolvedSource.startsWith(resolvedSourceDir + '/') && resolvedSource !== resolvedSourceDir) {
|
|
123
|
+
console.warn(warning(` Path traversal blocked: ${imagePath}`));
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
133
126
|
// Get the relative path from source dir to maintain folder structure
|
|
134
127
|
destRelPath = relative(sourceDir, fullPath);
|
|
135
128
|
}
|
|
136
129
|
// Check if image exists
|
|
137
130
|
if (!(await fs.pathExists(sourcePath))) {
|
|
138
|
-
console.warn(
|
|
131
|
+
console.warn(warning(` Image not found: ${sourcePath}`));
|
|
139
132
|
continue;
|
|
140
133
|
}
|
|
141
134
|
const destPath = join(outputDir, destRelPath);
|