project-logbook 0.3.1 → 0.3.3

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/README.md CHANGED
@@ -17,18 +17,21 @@ Each entry in `logbook/` consists of:
17
17
 
18
18
  ## Installation
19
19
  ```bash
20
- npm i project-loogbook -g
20
+ npm i project-logbook -g
21
21
  ```
22
22
 
23
23
  ## Commands
24
- - `logbook init`: Initialize configuration and directory.
25
- - `logbook new <id> <slug>`: Create a new entry folder with templates.
26
- - `logbook start <id>`: Mark a logbook entry as active (writes `.logbook-active` lockfile). The active entry is also auto-detected from the current Git branch — a branch named `feat/LB-123-my-feature` will automatically resolve to entry `LB-123`.
27
- - `logbook release`: Release the active logbook entry (removes `.logbook-active` lockfile).
28
- - `logbook log <message>`: Append a timestamped log entry to the active `log.md`.
29
- - `logbook list`: List all logbook entries with their status.
30
- - `logbook lint`: Validate structure, frontmatter, and internal links.
31
- - `logbook build`: Compile logbook entries into a static HTML site (default: `public/`).
32
- - `logbook preview`: Build and open the logbook in your default browser.
33
- - `logbook upgrade`: Synchronize core project files (like `CONTRIBUTING.md`) with latest templates.
34
- - `logbook steer`: Output agentic protocol for AI assistants.
24
+
25
+ | Command | Description |
26
+ |---|---|
27
+ | `logbook init` | Initialize configuration and directory. |
28
+ | `logbook new <id> <slug>` | Create a new entry folder with templates. |
29
+ | `logbook start <id>` | Mark a logbook entry as active (writes `.logbook-active` lockfile). The active entry is also auto-detected from the current Git branch — a branch named `feat/LB-123-my-feature` will automatically resolve to entry `LB-123`. |
30
+ | `logbook release` | Release the active logbook entry (removes `.logbook-active` lockfile). |
31
+ | `logbook log <message>` | Append a timestamped log entry to the active `log.md`. |
32
+ | `logbook list` | List all logbook entries with their status. |
33
+ | `logbook lint` | Validate structure, frontmatter, and internal links. |
34
+ | `logbook build` | Compile logbook entries into a static HTML site (default: `public/`). |
35
+ | `logbook preview` | Build and open the logbook in your default browser. |
36
+ | `logbook upgrade` | Synchronize core project files (like `CONTRIBUTING.md`) with latest templates. |
37
+ | `logbook steer` | Output agentic protocol for AI assistants. |
@@ -5,14 +5,25 @@ import { getConfig, getWorkspaces } from '../lib/config.js';
5
5
  import { layout, timelineTemplate } from '../lib/templates.js';
6
6
  import { getStyles } from '../lib/styles.js';
7
7
  import { getClientScript } from '../lib/logbook-client.js';
8
- import { mdToHtml, buildProjectMdFiles, renderPost } from '../lib/build-helpers.js';
8
+ import { mdToHtml, buildProjectMdFiles, renderPost, generateBuildMeta } from '../lib/build-helpers.js';
9
9
  import { mdToHtmlWithImages, copyImages } from '../lib/image-helpers.js';
10
10
  import { getGitCommits, getGitTags } from '../lib/git-helpers.js';
11
11
  import { formatRelativeDate, getMonthYear, getSortTime } from '../utils/date.js';
12
12
  import { getLogbookEntries } from '../utils/fs.js';
13
13
  import { getAboutContent } from '../lib/about-content.js';
14
+ import { groupTimelineItems } from '../utils/timeline-helpers.js';
14
15
  const pkg = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
15
16
  const version = pkg.version;
17
+ /** Normalises a frontmatter field that may be a scalar string or a YAML list of strings. */
18
+ const toDisplayString = (v) => {
19
+ if (typeof v === 'string')
20
+ return v || undefined;
21
+ if (Array.isArray(v)) {
22
+ const joined = v.filter((x) => typeof x === 'string').join(' + ');
23
+ return joined || undefined;
24
+ }
25
+ return undefined;
26
+ };
16
27
  export async function build() {
17
28
  const config = getConfig();
18
29
  const logbookDir = join(process.cwd(), config.logbookDir);
@@ -102,9 +113,9 @@ export async function build() {
102
113
  tags: Array.isArray(data.tags) ? data.tags.filter((t) => typeof t === 'string') : [],
103
114
  workspaces: entryWorkspaces,
104
115
  title: typeof data.title === 'string' ? data.title : undefined,
105
- harness: typeof data.harness === 'string' ? data.harness : undefined,
106
- llm: typeof data.llm === 'string' ? data.llm : undefined,
107
- prompter: typeof data.prompter === 'string' ? data.prompter : undefined,
116
+ harness: toDisplayString(data.harness),
117
+ llm: toDisplayString(data.llm),
118
+ prompter: toDisplayString(data.prompter),
108
119
  });
109
120
  }
110
121
  // Fetch global git commits and tags concurrently.
@@ -134,18 +145,49 @@ export async function build() {
134
145
  return bEnd - aEnd;
135
146
  });
136
147
  await Promise.all(timelineEntries.map((entry, i) => renderPost(entry, i, timelineEntries, { logbookDir, outputDir, config, version, buildTime })));
137
- const groups = groupTimelineItems(timelineEntries, commitItems, tagItems);
138
- const timelineContent = timelineTemplate({ projectName: config.projectName, version, buildTime, groups, readmeHtml, aboutHtml, jiraBaseUrl: config.jiraBaseUrl, jiraPrefix: config.jiraPrefix }); // prettier-ignore
139
- const buildMeta = `Generated by ${config.projectName} v${version} • ${buildTime}`;
140
- const timelineHtml = layout({
141
- title: 'Timeline',
142
- projectName: config.projectName,
143
- basePath: './',
144
- buildMeta,
145
- header: timelineContent.split('</header>')[0] + '</header>',
146
- content: timelineContent.split('</header>')[1],
147
- });
148
- await fs.writeFile(join(outputDir, 'index.html'), timelineHtml);
148
+ // Build the full merged, chronologically sorted groups once, then paginate the flat item list.
149
+ const allGroups = groupTimelineItems(timelineEntries, commitItems, tagItems);
150
+ const flatItems = allGroups.flatMap((g) => g.items);
151
+ const pageSize = 50;
152
+ const totalPages = Math.max(1, Math.ceil(flatItems.length / pageSize));
153
+ for (let i = 0; i < totalPages; i++) {
154
+ const currentPage = i + 1;
155
+ const pageItems = flatItems.slice(i * pageSize, (i + 1) * pageSize);
156
+ // Re-group this page's items by month for rendering.
157
+ const pageGroups = [];
158
+ for (const item of pageItems) {
159
+ const month = item.monthGroup;
160
+ let group = pageGroups.find((g) => g.month === month);
161
+ if (!group) {
162
+ group = { month, items: [] };
163
+ pageGroups.push(group);
164
+ }
165
+ group.items.push(item);
166
+ }
167
+ const timelineContent = timelineTemplate({
168
+ projectName: config.projectName,
169
+ version,
170
+ buildTime,
171
+ groups: pageGroups,
172
+ readmeHtml,
173
+ aboutHtml,
174
+ jiraBaseUrl: config.jiraBaseUrl,
175
+ jiraPrefix: config.jiraPrefix,
176
+ currentPage,
177
+ totalPages,
178
+ }); // prettier-ignore
179
+ const buildMeta = generateBuildMeta(version, buildTime);
180
+ const timelineHtml = layout({
181
+ title: 'Timeline',
182
+ projectName: config.projectName,
183
+ basePath: './',
184
+ buildMeta,
185
+ header: timelineContent.split('</header>')[0] + '</header>',
186
+ content: timelineContent.split('</header>')[1],
187
+ });
188
+ const fileName = currentPage === 1 ? 'index.html' : `index-${currentPage}.html`;
189
+ await fs.writeFile(join(outputDir, fileName), timelineHtml);
190
+ }
149
191
  await buildProjectMdFiles(process.cwd(), outputDir, [config.logbookDir, config.outputDir, 'node_modules'], {
150
192
  config,
151
193
  version,
@@ -153,22 +195,3 @@ export async function build() {
153
195
  });
154
196
  console.log(chalk.green(`\nSuccessfully built logbook to ${config.outputDir}/`));
155
197
  }
156
- function groupTimelineItems(entries, commits, tags) {
157
- const toMs = (item) => {
158
- if (item.kind === 'entry')
159
- return item.dateEnd ? new Date(item.dateEnd).getTime() : new Date(item.dateStart).getTime();
160
- return new Date(item.timestamp).getTime();
161
- };
162
- const all = [...entries, ...commits, ...tags];
163
- all.sort((a, b) => toMs(b) - toMs(a));
164
- const groups = [];
165
- for (const item of all) {
166
- let group = groups.find((g) => g.month === item.monthGroup);
167
- if (!group) {
168
- group = { month: item.monthGroup, items: [] };
169
- groups.push(group);
170
- }
171
- group.items.push(item);
172
- }
173
- return groups;
174
- }
@@ -20,7 +20,7 @@ export async function lint() {
20
20
  // 2. Entry-level checks
21
21
  const logbookEntries = await getLogbookEntries(logbookDir);
22
22
  let passedCount = 0;
23
- let skippedCount = 0;
23
+ let skippedCount = 0; // Re-introducing skippedCount
24
24
  for (const entry of logbookEntries) {
25
25
  if (!entry.hasIndex) {
26
26
  console.error(chalk.red(` [MISSING] ${entry.slug}/index.md`));
@@ -62,17 +62,9 @@ export async function lint() {
62
62
  * meaning it should be exempt from linting.
63
63
  */
64
64
  function isDraft(entry) {
65
- const dataValues = Object.values(entry.data)
66
- .filter((v) => typeof v === 'string')
67
- .join('\n');
68
- const frontmatterHasPlaceholders = dataValues.includes('[DATE_END]') ||
69
- dataValues.includes('[DATE_START]') ||
70
- dataValues.includes('[WRITE_SUMMARY_HERE]') ||
71
- dataValues.includes('[PROMPTER]') ||
72
- dataValues.includes('[HARNESS]') ||
73
- dataValues.includes('[LLM]');
74
- const contentWithoutCode = entry.content.replace(/`[^`]*`/g, '');
75
- const bodyHasPlaceholders = contentWithoutCode.includes('TODO:') ||
76
- entry.content.includes('Write a polished, highly readable "short story" of the change here.');
77
- return frontmatterHasPlaceholders || bodyHasPlaceholders;
65
+ const frontmatter = entry.data; // Correctly access the data property
66
+ // An entry is a draft if:
67
+ // 1. The 'dateStart' key is missing from frontmatter (i.e., undefined or null).
68
+ // 2. OR the string representation of 'dateStart' is '[DATE_START]'.
69
+ return !frontmatter?.dateStart || String(frontmatter.dateStart) === 'DATE_START';
78
70
  }
package/dist/index.js CHANGED
@@ -75,12 +75,12 @@ program
75
75
  await list(options);
76
76
  });
77
77
  program
78
- .command('log <message...>')
78
+ .command('log <message>')
79
79
  .description('Append a timestamped log entry to the active log.md (e.g. logbook log "Did a thing")')
80
80
  .option('-i, --id <id>', 'Override active logbook entry ID')
81
- .action(async (messages, options) => {
81
+ .action(async (message, options) => {
82
82
  const { log } = await import('./commands/log.js');
83
- await log(messages, options);
83
+ await log([message], options);
84
84
  });
85
85
  program
86
86
  .command('steer')
@@ -1,6 +1,8 @@
1
1
  import type { LogbookConfig } from './config.js';
2
2
  import type { TimelineEntry } from './template-types.js';
3
3
  export declare function mdToHtml(md: string): Promise<string>;
4
+ /** Process entry markdown with image path rewriting */
5
+ export declare function mdToHtmlWithImagePathRewrite(md: string): Promise<string>;
4
6
  export declare function buildProjectMdFiles(projectRoot: string, outputDir: string, excludeDirs: string[], ctx: {
5
7
  config: LogbookConfig;
6
8
  version: string;
@@ -13,3 +15,8 @@ export declare function renderPost(data: TimelineEntry, i: number, allEntries: T
13
15
  version: string;
14
16
  buildTime: string;
15
17
  }): Promise<void>;
18
+ /**
19
+ * Generate the standard footer metadata string.
20
+ * Always hardcoded to 'Project Logbook CLI' with link to npm.
21
+ */
22
+ export declare function generateBuildMeta(version: string, buildTime: string): string;
@@ -2,6 +2,7 @@ import fs from 'fs-extra';
2
2
  import { join } from 'node:path';
3
3
  import { unified } from 'unified';
4
4
  import remarkParse from 'remark-parse';
5
+ import remarkGfm from 'remark-gfm';
5
6
  import remarkRehype from 'remark-rehype';
6
7
  import rehypeSlug from 'rehype-slug';
7
8
  import rehypeFormat from 'rehype-format';
@@ -9,7 +10,7 @@ import rehypeStringify from 'rehype-stringify';
9
10
  import matter from 'gray-matter';
10
11
  import { layout, postTemplate } from './templates.js';
11
12
  import { getGitCommits } from './git-helpers.js';
12
- import { mdToHtmlWithImages, extractImagePathsFromMarkdown, copyImages } from './image-helpers.js';
13
+ import { mdToHtmlWithImages, extractImagePathsFromMarkdown, copyImages, rehypeRewriteImagePaths, } from './image-helpers.js';
13
14
  /** Rehype plugin: rewrite relative .md links to /index.html equivalents. */
14
15
  const rehypeRewriteMdLinks = () => {
15
16
  return (tree) => {
@@ -39,15 +40,31 @@ function visitLinks(node, visitor) {
39
40
  }
40
41
  const processor = unified()
41
42
  .use(remarkParse)
43
+ .use(remarkGfm)
42
44
  .use(remarkRehype)
43
45
  .use(rehypeSlug)
44
46
  .use(rehypeRewriteMdLinks)
45
47
  .use(rehypeFormat)
46
48
  .use(rehypeStringify);
49
+ /** Processor for entry content with image path rewriting */
50
+ const entryProcessor = unified()
51
+ .use(remarkParse)
52
+ .use(remarkGfm)
53
+ .use(remarkRehype)
54
+ .use(rehypeSlug)
55
+ .use(rehypeRewriteMdLinks)
56
+ .use(rehypeRewriteImagePaths)
57
+ .use(rehypeFormat)
58
+ .use(rehypeStringify);
47
59
  export async function mdToHtml(md) {
48
60
  const result = await processor.process(md);
49
61
  return result.toString();
50
62
  }
63
+ /** Process entry markdown with image path rewriting */
64
+ export async function mdToHtmlWithImagePathRewrite(md) {
65
+ const result = await entryProcessor.process(md);
66
+ return result.toString();
67
+ }
51
68
  export async function buildProjectMdFiles(projectRoot, outputDir, excludeDirs, ctx) {
52
69
  const entries = await fs.readdir(projectRoot, { withFileTypes: true });
53
70
  for (const entry of entries) {
@@ -60,7 +77,7 @@ export async function buildProjectMdFiles(projectRoot, outputDir, excludeDirs, c
60
77
  const outDir = join(outputDir, stem);
61
78
  const mdContent = await fs.readFile(sourcePath, 'utf8');
62
79
  const bodyHtml = await mdToHtml(mdContent);
63
- const buildMeta = `Generated by ${ctx.config.projectName} v${ctx.version} • ${ctx.buildTime}`;
80
+ const buildMeta = generateBuildMeta(ctx.version, ctx.buildTime);
64
81
  const pageHtml = layout({
65
82
  title: stem,
66
83
  projectName: ctx.config.projectName,
@@ -101,7 +118,7 @@ async function renderLinkedMdFiles(markdownSources, entryPath, entryOutputDir, c
101
118
  const mdContent = await fs.readFile(sourcePath, 'utf8');
102
119
  const bodyHtml = await mdToHtml(mdContent);
103
120
  const title = href.replace(/\.md$/i, '');
104
- const buildMeta = `Generated by ${ctx.config.projectName} v${ctx.version} • ${ctx.buildTime}`;
121
+ const buildMeta = generateBuildMeta(ctx.version, ctx.buildTime);
105
122
  const pageHtml = layout({
106
123
  title,
107
124
  projectName: ctx.config.projectName,
@@ -139,8 +156,8 @@ export async function renderPost(data, i, allEntries, ctx) {
139
156
  title: data.title ?? data.slug,
140
157
  harness: data.harness ?? '',
141
158
  content: storyHtml,
142
- ticketHtml: ticketRaw ? await mdToHtml(ticketRaw) : '',
143
- logHtml: logRaw ? await mdToHtml(logRaw) : '',
159
+ ticketHtml: ticketRaw ? await mdToHtmlWithImagePathRewrite(ticketRaw) : '',
160
+ logHtml: logRaw ? await mdToHtmlWithImagePathRewrite(logRaw) : '',
144
161
  commits,
145
162
  version: ctx.version,
146
163
  jiraUrl,
@@ -148,7 +165,7 @@ export async function renderPost(data, i, allEntries, ctx) {
148
165
  nextEntry: next ? { slug: next.slug, title: next.title ?? '', ticket: next.ticket } : null,
149
166
  });
150
167
  const descriptionRaw = typeof data.summary === 'string' ? data.summary.replace(/[*#`]/g, '').trim() : '';
151
- const buildMeta = `Generated by ${ctx.config.projectName} v${ctx.version} • ${ctx.buildTime}`;
168
+ const buildMeta = generateBuildMeta(ctx.version, ctx.buildTime);
152
169
  const postHtml = layout({
153
170
  title: data.title ?? data.slug,
154
171
  projectName: ctx.config.projectName,
@@ -169,3 +186,10 @@ export async function renderPost(data, i, allEntries, ctx) {
169
186
  }
170
187
  await renderLinkedMdFiles([summaryContent, ticketRaw, logRaw], entryPath, entryOutputDir, ctx);
171
188
  }
189
+ /**
190
+ * Generate the standard footer metadata string.
191
+ * Always hardcoded to 'Project Logbook CLI' with link to npm.
192
+ */
193
+ export function generateBuildMeta(version, buildTime) {
194
+ return `Generated by <a href="https://www.npmjs.com/package/project-logbook" target="_blank" rel="noopener noreferrer">Project Logbook CLI</a> v${version} • ${buildTime}`;
195
+ }
@@ -2,9 +2,20 @@ import type { GitCommit } from './template-types.js';
2
2
  /**
3
3
  * Parse the raw output of `git log --pretty=format:"%H|%s|%aI"` into GitCommit objects.
4
4
  * Pure function (no I/O) — unit-testable without spawning git.
5
+ *
6
+ * Uses indexOf/lastIndexOf to delimit fields so commit messages containing `|`
7
+ * are preserved correctly instead of being silently truncated.
5
8
  */
6
9
  export declare function parseGitLogOutput(raw: string, now?: Date): GitCommit[];
10
+ /**
11
+ * Fetch git commits that touched `dir`, up to `maxCount`.
12
+ * Returns an empty array if git is unavailable or the directory is not a repo.
13
+ */
7
14
  export declare function getGitCommits(dir: string, maxCount?: number): Promise<GitCommit[]>;
15
+ /**
16
+ * Fetch all git tags sorted by creation date (newest first).
17
+ * Returns an empty array if git is unavailable or the directory is not a repo.
18
+ */
8
19
  export declare function getGitTags(): Promise<{
9
20
  name: string;
10
21
  timestamp: string;
@@ -1,8 +1,14 @@
1
1
  import { execSync } from 'node:child_process';
2
2
  import { simpleGit } from 'simple-git';
3
+ function getGit() {
4
+ return simpleGit(process.cwd());
5
+ }
3
6
  /**
4
7
  * Parse the raw output of `git log --pretty=format:"%H|%s|%aI"` into GitCommit objects.
5
8
  * Pure function (no I/O) — unit-testable without spawning git.
9
+ *
10
+ * Uses indexOf/lastIndexOf to delimit fields so commit messages containing `|`
11
+ * are preserved correctly instead of being silently truncated.
6
12
  */
7
13
  export function parseGitLogOutput(raw, now = new Date()) {
8
14
  if (!raw.trim())
@@ -11,7 +17,13 @@ export function parseGitLogOutput(raw, now = new Date()) {
11
17
  .split('\n')
12
18
  .filter(Boolean)
13
19
  .map((line) => {
14
- const [sha, message, timestamp] = line.split('|');
20
+ const firstPipe = line.indexOf('|');
21
+ const lastPipe = line.lastIndexOf('|');
22
+ if (firstPipe === -1 || firstPipe === lastPipe)
23
+ return null;
24
+ const sha = line.slice(0, firstPipe);
25
+ const message = line.slice(firstPipe + 1, lastPipe);
26
+ const timestamp = line.slice(lastPipe + 1);
15
27
  if (!sha || !message || !timestamp)
16
28
  return null;
17
29
  const relativeTime = formatRelativeTime(timestamp, now);
@@ -42,20 +54,31 @@ function formatRelativeTime(isoTimestamp, now) {
42
54
  const diffYear = Math.floor(diffMonth / 12);
43
55
  return `${diffYear} year${diffYear === 1 ? '' : 's'} ago`;
44
56
  }
57
+ /**
58
+ * Fetch git commits that touched `dir`, up to `maxCount`.
59
+ * Returns an empty array if git is unavailable or the directory is not a repo.
60
+ */
45
61
  export async function getGitCommits(dir, maxCount = 100) {
46
62
  try {
47
- const git = simpleGit(process.cwd());
48
- const raw = await git.raw(['log', '--follow', `-n`, String(maxCount), '--pretty=format:%H|%s|%aI', '--', dir]);
63
+ const raw = await getGit().raw(['log', '--follow', `-n`, String(maxCount), '--pretty=format:%H|%s|%aI', '--', dir]);
49
64
  return parseGitLogOutput(raw);
50
65
  }
51
66
  catch {
52
67
  return [];
53
68
  }
54
69
  }
70
+ /**
71
+ * Fetch all git tags sorted by creation date (newest first).
72
+ * Returns an empty array if git is unavailable or the directory is not a repo.
73
+ */
55
74
  export async function getGitTags() {
56
75
  try {
57
- const git = simpleGit(process.cwd());
58
- const raw = await git.raw(['tag', '-l', '--sort=-creatordate', '--format=%(creatordate:iso8601)|%(refname:short)']);
76
+ const raw = await getGit().raw([
77
+ 'tag',
78
+ '-l',
79
+ '--sort=-creatordate',
80
+ '--format=%(creatordate:iso8601)|%(refname:short)',
81
+ ]);
59
82
  return raw
60
83
  .split('\n')
61
84
  .filter(Boolean)
@@ -1,7 +1,11 @@
1
+ import type { Root } from 'hast';
2
+ import type { Plugin } from 'unified';
1
3
  /**
2
4
  * Extract image paths from markdown content
3
5
  */
4
6
  export declare function extractImagePathsFromMarkdown(content: string): string[];
7
+ /** Rehype plugin: rewrite relative image paths to use /images/ subfolder */
8
+ export declare const rehypeRewriteImagePaths: Plugin<[], Root>;
5
9
  /**
6
10
  * Process markdown with image collection enabled
7
11
  * Returns { html, imagePaths }
@@ -2,6 +2,7 @@ import fs from 'fs-extra';
2
2
  import { join, dirname, relative } from 'node:path';
3
3
  import { unified } from 'unified';
4
4
  import remarkParse from 'remark-parse';
5
+ import remarkGfm from 'remark-gfm';
5
6
  import remarkRehype from 'remark-rehype';
6
7
  import rehypeSlug from 'rehype-slug';
7
8
  import rehypeFormat from 'rehype-format';
@@ -57,6 +58,29 @@ function visitImages(node, visitor) {
57
58
  }
58
59
  }
59
60
  }
61
+ /** Rehype plugin: rewrite relative image paths to use /images/ subfolder */
62
+ export const rehypeRewriteImagePaths = () => {
63
+ return (tree) => {
64
+ visitImages(tree, (node) => {
65
+ const src = node.properties?.src;
66
+ if (typeof src !== 'string')
67
+ return;
68
+ // Skip external URLs and data URIs
69
+ if (/^[a-z][a-z\d+\-.]*:/i.test(src))
70
+ return;
71
+ if (src.startsWith('data:'))
72
+ return;
73
+ // Skip already rewritten paths
74
+ if (src.startsWith('./images/') || src.startsWith('../images/'))
75
+ return;
76
+ // Skip absolute paths
77
+ if (src.startsWith('/'))
78
+ return;
79
+ // Rewrite relative paths to use /images/ subfolder
80
+ node.properties.src = `./images/${src}`;
81
+ });
82
+ };
83
+ };
60
84
  /**
61
85
  * Process markdown with image collection enabled
62
86
  * Returns { html, imagePaths }
@@ -65,8 +89,10 @@ export async function mdToHtmlWithImages(md) {
65
89
  imageProcessor.reset();
66
90
  const processorWithImages = unified()
67
91
  .use(remarkParse)
92
+ .use(remarkGfm)
68
93
  .use(remarkRehype)
69
94
  .use(imageProcessor.rehypeCollectImages)
95
+ .use(rehypeRewriteImagePaths)
70
96
  .use(rehypeSlug)
71
97
  .use(rehypeFormat)
72
98
  .use(rehypeStringify);
@@ -0,0 +1,8 @@
1
+ import type { TimelineEntry, TimelineCommitItem, TimelineTagItem, EntryLink, GitCommit } from './template-types.js';
2
+ export declare const navLink: (entry: EntryLink, dir: "prev" | "next") => string;
3
+ export declare const renderTagsAndWorkspaces: (tags: string[] | undefined, workspaces: string[] | undefined) => string;
4
+ export declare const renderCommits: (commits: GitCommit[] | undefined) => string;
5
+ export declare const renderTimelineEntryItem: (e: TimelineEntry) => string;
6
+ export declare const renderTimelineCommitItem: (c: TimelineCommitItem, jiraBaseUrl?: string, jiraPrefix?: string) => string;
7
+ export declare const renderTimelineTagItem: (t: TimelineTagItem) => string;
8
+ export declare const paginationLinks: (currentPage: number, totalPages: number) => string;
@@ -0,0 +1,78 @@
1
+ import { linkJiraIds } from './jira-helpers.js';
2
+ const html = (strings, ...values) => {
3
+ return strings.reduce((acc, str, i) => acc + str + (values[i] ?? ''), '');
4
+ };
5
+ export const navLink = (entry, dir) => {
6
+ const label = dir === 'prev' ? '← Previous' : 'Next →';
7
+ const t = `${entry.ticket ? `${entry.ticket}: ` : ''}${entry.title}`;
8
+ return `<a href="../${entry.slug}/index.html" class="post-nav-link"><span class="post-nav-label">${label}</span><span class="post-nav-title">${t}</span></a>`;
9
+ };
10
+ const renderWorkspaces = (workspaces) => {
11
+ if (!workspaces || workspaces.length === 0)
12
+ return '';
13
+ return workspaces.map((ws) => `<span class="tag-badge tag-workspace">${ws}</span>`).join('');
14
+ };
15
+ export const renderTagsAndWorkspaces = (tags, workspaces) => {
16
+ const tagsHtml = tags && tags.length > 0
17
+ ? tags.map((tag) => `<span class="tag-badge tag-${tag.replace('#', '')}">${tag}</span>`).join('')
18
+ : '';
19
+ const wsHtml = renderWorkspaces(workspaces);
20
+ if (!tagsHtml && !wsHtml)
21
+ return '';
22
+ return html `<div class="tags-wrapper">${tagsHtml}${wsHtml}</div>`;
23
+ };
24
+ // Renders a compact list of git commits to embed in the Technical Log tab.
25
+ export const renderCommits = (commits) => {
26
+ if (!commits || commits.length === 0)
27
+ return '';
28
+ const rows = commits
29
+ .map((c) => `<div class="commit-item"><span class="commit-sha">${c.sha}</span><span class="commit-message">${c.message}</span><span class="commit-time" title="${c.timestamp}">${c.relativeTime}</span></div>`)
30
+ .join('');
31
+ return `<div class="commit-list"><h3 class="commit-list-heading">Git Commits</h3>${rows}</div>`;
32
+ };
33
+ export const renderTimelineEntryItem = (e) => html `<div class="timeline-item" data-entry-slug="${e.slug}">
34
+ <a class="timeline-card" href="./${e.slug}/index.html"
35
+ ><div class="item-content">
36
+ <div class="item-meta">
37
+ <span data-date="${e.dateStart}">${e.displayDate}</span> • <span class="sort-time">${e.sortTime}</span> •
38
+ ${e.llm ? ` ${e.llm} via ` : ''}${e.harness}${e.prompter ? ` / ${e.prompter}` : ''}
39
+ </div>
40
+ <h3 class="item-title">
41
+ <span class="new-badge" style="display:none;">NEW</span>${e.ticket ? `${e.ticket}: ` : ''}${e.title}
42
+ </h3>
43
+ <div class="item-summary">${e.summary}</div>
44
+ ${renderTagsAndWorkspaces(e.tags, e.workspaces)}
45
+ </div>
46
+ <span class="item-arrow">›</span></a
47
+ >
48
+ </div>`;
49
+ export const renderTimelineCommitItem = (c, jiraBaseUrl, jiraPrefix) => {
50
+ const message = jiraBaseUrl && jiraPrefix ? linkJiraIds(c.message, jiraBaseUrl, jiraPrefix) : c.message;
51
+ return `<div class="timeline-item timeline-item--commit"><div class="commit-chip"><span class="commit-sha">${c.sha}</span><span class="commit-message">${message}</span><span class="commit-time" title="${c.timestamp}" data-date="${c.timestamp}">${c.relativeTime}</span></div></div>`;
52
+ };
53
+ export const renderTimelineTagItem = (t) => {
54
+ const formatted = new Date(t.timestamp)
55
+ .toLocaleString('de-DE', {
56
+ year: 'numeric',
57
+ month: '2-digit',
58
+ day: '2-digit',
59
+ hour: '2-digit',
60
+ minute: '2-digit',
61
+ second: '2-digit',
62
+ hour12: false,
63
+ })
64
+ .replace(',', '');
65
+ return `<div class="timeline-item timeline-item--tag"><div class="tag-chip"><span class="tag-chip-icon">🏷</span><span class="tag-chip-name">${t.name}</span><span class="tag-chip-date" title="${t.timestamp}">${formatted}</span></div></div>`;
66
+ };
67
+ export const paginationLinks = (currentPage, totalPages) => {
68
+ let links = '';
69
+ if (currentPage > 1) {
70
+ const prevPageLink = currentPage === 2 ? 'index.html' : `index-${currentPage - 1}.html`;
71
+ links += `<a href="./${prevPageLink}" class="pagination-link pagination-link--prev">← Previous Page</a>`;
72
+ }
73
+ if (currentPage < totalPages) {
74
+ const nextPageLink = `index-${currentPage + 1}.html`;
75
+ links += `<a href="./${nextPageLink}" class="pagination-link pagination-link--next">Next Page →</a>`;
76
+ }
77
+ return links ? `<nav class="pagination-nav">${links}</nav>` : '';
78
+ };
@@ -18,6 +18,8 @@ export interface TimelineTemplateProps {
18
18
  aboutHtml: string;
19
19
  jiraBaseUrl?: string;
20
20
  jiraPrefix?: string;
21
+ currentPage?: number;
22
+ totalPages?: number;
21
23
  }
22
24
  /**
23
25
  * Configuration for a month group in the timeline
@@ -40,6 +42,7 @@ export interface TimelineEntry {
40
42
  dateEnd?: string;
41
43
  displayDate: string;
42
44
  sortTime: string;
45
+ monthGroup: string;
43
46
  harness?: string;
44
47
  llm?: string;
45
48
  prompter?: string;
@@ -1,4 +1,4 @@
1
- import { linkJiraIds } from './jira-helpers.js';
1
+ import { navLink, renderTagsAndWorkspaces, renderCommits, renderTimelineEntryItem, renderTimelineCommitItem, renderTimelineTagItem, paginationLinks, } from './template-helpers.js';
2
2
  const html = (strings, ...values) => {
3
3
  return strings.reduce((acc, str, i) => acc + str + (values[i] ?? ''), '');
4
4
  };
@@ -28,70 +28,8 @@ export const layout = ({ title, header, content, projectName, basePath = './', d
28
28
  <footer>${buildMeta ? `<p class="build-time">${buildMeta}</p>` : ''}</footer>
29
29
  <script src="${basePath}logbook.js"></script>
30
30
  </body></html>`;
31
- const navLink = (entry, dir) => {
32
- const label = dir === 'prev' ? '← Previous' : 'Next →';
33
- const t = `${entry.ticket ? `${entry.ticket}: ` : ''}${entry.title}`;
34
- return `<a href="../${entry.slug}/index.html" class="post-nav-link"><span class="post-nav-label">${label}</span><span class="post-nav-title">${t}</span></a>`;
35
- };
36
- const renderWorkspaces = (workspaces) => {
37
- if (!workspaces || workspaces.length === 0)
38
- return '';
39
- return workspaces.map((ws) => `<span class="tag-badge tag-workspace">${ws}</span>`).join('');
40
- };
41
- const renderTagsAndWorkspaces = (tags, workspaces) => {
42
- const tagsHtml = tags && tags.length > 0
43
- ? tags.map((tag) => `<span class="tag-badge tag-${tag.replace('#', '')}">${tag}</span>`).join('')
44
- : '';
45
- const wsHtml = renderWorkspaces(workspaces);
46
- if (!tagsHtml && !wsHtml)
47
- return '';
48
- return html `<div class="tags-wrapper">${tagsHtml}${wsHtml}</div>`;
49
- };
50
- // Renders a compact list of git commits to embed in the Technical Log tab.
51
- const renderCommits = (commits) => {
52
- if (!commits || commits.length === 0)
53
- return '';
54
- const rows = commits
55
- .map((c) => `<div class="commit-item"><span class="commit-sha">${c.sha}</span><span class="commit-message">${c.message}</span><span class="commit-time" title="${c.timestamp}">${c.relativeTime}</span></div>`)
56
- .join('');
57
- return `<div class="commit-list"><h3 class="commit-list-heading">Git Commits</h3>${rows}</div>`;
58
- };
59
- const renderTimelineEntryItem = (e) => html `<div class="timeline-item" data-entry-slug="${e.slug}">
60
- <a class="timeline-card" href="./${e.slug}/index.html"
61
- ><div class="item-content">
62
- <div class="item-meta">
63
- <span data-date="${e.dateStart}">${e.displayDate}</span> • <span class="sort-time">${e.sortTime}</span> •
64
- ${e.llm ? ` ${e.llm} via ` : ''}${e.harness}${e.prompter ? ` / ${e.prompter}` : ''}
65
- </div>
66
- <h3 class="item-title">
67
- <span class="new-badge" style="display:none;">NEW</span>${e.ticket ? `${e.ticket}: ` : ''}${e.title}
68
- </h3>
69
- <div class="item-summary">${e.summary}</div>
70
- ${renderTagsAndWorkspaces(e.tags, e.workspaces)}
71
- </div>
72
- <span class="item-arrow">›</span></a
73
- >
74
- </div>`;
75
- const renderTimelineCommitItem = (c, jiraBaseUrl, jiraPrefix) => {
76
- const message = jiraBaseUrl && jiraPrefix ? linkJiraIds(c.message, jiraBaseUrl, jiraPrefix) : c.message;
77
- return `<div class="timeline-item timeline-item--commit"><div class="commit-chip"><span class="commit-sha">${c.sha}</span><span class="commit-message">${message}</span><span class="commit-time" title="${c.timestamp}" data-date="${c.timestamp}">${c.relativeTime}</span></div></div>`;
78
- };
79
- const renderTimelineTagItem = (t) => {
80
- const formatted = new Date(t.timestamp)
81
- .toLocaleString('de-DE', {
82
- year: 'numeric',
83
- month: '2-digit',
84
- day: '2-digit',
85
- hour: '2-digit',
86
- minute: '2-digit',
87
- second: '2-digit',
88
- hour12: false,
89
- })
90
- .replace(',', '');
91
- return `<div class="timeline-item timeline-item--tag"><div class="tag-chip"><span class="tag-chip-icon">🏷</span><span class="tag-chip-name">${t.name}</span><span class="tag-chip-date" title="${t.timestamp}">${formatted}</span></div></div>`;
92
- };
93
31
  export const timelineTemplate = (props) => {
94
- const { projectName, groups, readmeHtml, aboutHtml, jiraBaseUrl, jiraPrefix } = props;
32
+ const { projectName, groups, readmeHtml, aboutHtml, jiraBaseUrl, jiraPrefix, currentPage, totalPages } = props;
95
33
  const timelineTab = html `<div class="timeline">
96
34
  ${groups
97
35
  .map((g) => html `<section class="month-group">
@@ -107,9 +45,10 @@ export const timelineTemplate = (props) => {
107
45
  .join('')}
108
46
  </section>`)
109
47
  .join('')}
48
+ ${paginationLinks(currentPage, totalPages)}
110
49
  </div>`;
111
50
  return html `<header>
112
- <h1>${projectName}</h1>
51
+ <h1><a href="./index.html">${projectName}</a></h1>
113
52
  <p class="tagline">A brief summary of the recent changes to the project.</p>
114
53
  </header>
115
54
  ${tabsComponent([
@@ -42,7 +42,23 @@ const linter = {
42
42
  });
43
43
  }
44
44
  }
45
- // 3. Tags validation
45
+ // 3. Validate specific placeholder values
46
+ const placeholderFields = ['prompter', 'harness', 'llm', 'summary'];
47
+ for (const field of placeholderFields) {
48
+ const value = frontmatter[field];
49
+ if (Array.isArray(value)) {
50
+ for (const item of value) {
51
+ if (typeof item === 'string' && (item === field.toUpperCase() || item === 'WRITE_SUMMARY_HERE')) {
52
+ issues.push({
53
+ level: 'error',
54
+ category: 'frontmatter',
55
+ message: `Placeholder value "${item}" found in field "${field}". Please replace with actual content.`,
56
+ });
57
+ }
58
+ }
59
+ }
60
+ }
61
+ // 4. Tags validation
46
62
  const allowedTags = config.tags?.allowed || [];
47
63
  if (!frontmatter.tags || !Array.isArray(frontmatter.tags) || frontmatter.tags.length === 0) {
48
64
  issues.push({
@@ -62,6 +78,10 @@ const linter = {
62
78
  }
63
79
  }
64
80
  }
81
+ // TODO: Add conditional validation for body placeholders based on isDraft
82
+ // Currently the isDraft only checks frontmatter data for placeholders.
83
+ // If the body contains "TODO:" and isDraft is true, the current logic is to skip.
84
+ // However, if we want to lint placeholder values even in draft mode, we need a separate body linter.
65
85
  return issues;
66
86
  },
67
87
  };
@@ -14,8 +14,6 @@ const linter = {
14
14
  if (context.indexPath && (await fs.pathExists(context.indexPath))) {
15
15
  rawFile = await fs.readFile(context.indexPath, 'utf8');
16
16
  }
17
- // Scan only the frontmatter block for bracket-style placeholders to avoid false
18
- // positives when placeholder names are mentioned in the narrative body.
19
17
  const frontmatterMatch = rawFile.match(/^---\n[\s\S]*?\n---/);
20
18
  const frontmatterBlock = frontmatterMatch ? frontmatterMatch[0] : rawFile;
21
19
  const frontmatterPlaceholders = [
@@ -37,7 +35,7 @@ const linter = {
37
35
  });
38
36
  }
39
37
  }
40
- // Check boilerplate body text against the full content (body only is fine here).
38
+ // Check boilerplate body text against the full content (body only is fine here) for all entries.
41
39
  if (context.content.includes('TODO: ')) {
42
40
  issues.push({
43
41
  level: 'error',
@@ -37,10 +37,10 @@ As you code, keep the `log.md` updated in real-time using the `logbook log` comm
37
37
 
38
38
  ```bash
39
39
  logbook log "Investigated root cause — found issue in src/lib/config.ts"
40
- logbook log "Fixed the bug" "Added tests" "All passing"
40
+ logbook log "Fixed the bug"
41
41
  ```
42
42
 
43
- Each message is automatically prefixed with an ISO timestamp and appended as a bullet to the active `log.md`. You may pass multiple quoted strings in one call. If you encounter a bug or change your design, log it immediately. This is the most valuable file for future debugging.
43
+ Each message is automatically prefixed with an ISO timestamp and appended as a bullet to the active `log.md`. One message per call. If you encounter a bug or change your design, log it immediately. This is the most valuable file for future debugging.
44
44
 
45
45
  Only update the currently active logbook entry. Do not edit other existing entries while working.
46
46
 
@@ -11,8 +11,8 @@ Phase 2: Execute & Trace
11
11
  1. Use `log.md` as a live technical scratchpad.
12
12
  2. Log your work in real-time using the `logbook log "<message>"` command. Record every major decision, error, and pivot. Do not reconstruct this at the end.
13
13
  Example: logbook log "Investigated root cause — found issue in src/lib/config.ts"
14
- 3. You may pass multiple messages in one call: logbook log "Msg A" "Msg B"
15
- 4. Stick to the active entry; never modify past entries in the logbook folder.
14
+ One message per call. For multiple entries, call the command once per message.
15
+ 3. Stick to the active entry; never modify past entries in the logbook folder.
16
16
 
17
17
  Phase 3: Synthesize
18
18
  1. When implementation is finished, write the narrative in `index.md`.
@@ -11,6 +11,11 @@ body {
11
11
  padding: 0;
12
12
  }
13
13
 
14
+ .home-link {
15
+ text-decoration: none;
16
+ color: inherit;
17
+ }
18
+
14
19
  header {
15
20
  background: var(--card-bg);
16
21
  border-bottom: 1px solid var(--border);
@@ -25,6 +30,11 @@ header h1 {
25
30
  letter-spacing: -0.025em;
26
31
  }
27
32
 
33
+ header h1 a {
34
+ text-decoration: none !important;
35
+ color: black !important;
36
+ }
37
+
28
38
  .tagline {
29
39
  color: var(--text-muted);
30
40
  font-size: 1.125rem;
@@ -273,6 +283,44 @@ article h1 {
273
283
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
274
284
  }
275
285
 
286
+ /* Pagination Links */
287
+ .pagination-nav {
288
+ display: flex;
289
+ justify-content: space-between;
290
+ margin-top: 2rem;
291
+ padding-top: 1.5rem;
292
+ border-top: 1px solid var(--border);
293
+ }
294
+
295
+ .pagination-link {
296
+ display: inline-flex;
297
+ align-items: center;
298
+ gap: 0.5rem;
299
+ font-size: 0.9375rem;
300
+ font-weight: 500;
301
+ color: var(--primary);
302
+ text-decoration: none;
303
+ padding: 0.5rem 1rem;
304
+ border-radius: 2rem;
305
+ transition:
306
+ background 0.2s,
307
+ color 0.2s;
308
+ background: var(--primary-soft);
309
+ }
310
+
311
+ .pagination-link:hover {
312
+ background: var(--primary);
313
+ color: white;
314
+ }
315
+
316
+ .pagination-link--prev {
317
+ margin-right: auto;
318
+ }
319
+
320
+ .pagination-link--next {
321
+ margin-left: auto;
322
+ }
323
+
276
324
  .content-section {
277
325
  animation: fadeIn 0.4s cubic-bezier(0.4, 0, 0.2, 1);
278
326
  font-size: 1.125rem;
@@ -286,6 +334,10 @@ article h1 {
286
334
  margin-bottom: 1rem;
287
335
  }
288
336
 
337
+ .content-section img {
338
+ max-width: 100%;
339
+ }
340
+
289
341
  @keyframes fadeIn {
290
342
  from {
291
343
  opacity: 0;
@@ -0,0 +1,4 @@
1
+ import { TimelineEntry, TimelineCommitItem, TimelineTagItem, TimelineGroup } from '../lib/template-types.js';
2
+ export declare function groupTimelineItems(entries: (TimelineEntry & {
3
+ monthGroup: string;
4
+ })[], commits: TimelineCommitItem[], tags: TimelineTagItem[]): TimelineGroup[];
@@ -0,0 +1,19 @@
1
+ export function groupTimelineItems(entries, commits, tags) {
2
+ const toMs = (item) => {
3
+ if (item.kind === 'entry')
4
+ return item.dateEnd ? new Date(item.dateEnd).getTime() : new Date(item.dateStart).getTime();
5
+ return new Date(item.timestamp).getTime();
6
+ };
7
+ const all = [...entries, ...commits, ...tags];
8
+ all.sort((a, b) => toMs(b) - toMs(a));
9
+ const groups = [];
10
+ for (const item of all) {
11
+ let group = groups.find((g) => g.month === item.monthGroup);
12
+ if (!group) {
13
+ group = { month: item.monthGroup, items: [] };
14
+ groups.push(group);
15
+ }
16
+ group.items.push(item);
17
+ }
18
+ return groups;
19
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-logbook",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "A command-line tool for project logbooks.",
5
5
  "workspaces": [
6
6
  "demo-app"
@@ -28,7 +28,8 @@
28
28
  "test": "vitest run",
29
29
  "pre": "npm run format && npm run lint && npm run test && npm run build && npm run knip && npm run madge && npm run dev build && npm run dev lint",
30
30
  "next": "node scripts/create-ticket-from-backlog.js",
31
- "link": "npm run build && npm link"
31
+ "link": "npm run build && npm link",
32
+ "open": "npm run build && node scripts/open-and-view-dist.js"
32
33
  },
33
34
  "keywords": [
34
35
  "logbook",
@@ -61,6 +62,7 @@
61
62
  "rehype-format": "^5.0.1",
62
63
  "rehype-slug": "^6.0.0",
63
64
  "rehype-stringify": "^10.0.1",
65
+ "remark-gfm": "^4.0.1",
64
66
  "remark-parse": "^11.0.0",
65
67
  "remark-rehype": "^11.1.2",
66
68
  "simple-git": "^3.36.0",
@@ -37,10 +37,10 @@ As you code, keep the `log.md` updated in real-time using the `logbook log` comm
37
37
 
38
38
  ```bash
39
39
  logbook log "Investigated root cause — found issue in src/lib/config.ts"
40
- logbook log "Fixed the bug" "Added tests" "All passing"
40
+ logbook log "Fixed the bug"
41
41
  ```
42
42
 
43
- Each message is automatically prefixed with an ISO timestamp and appended as a bullet to the active `log.md`. You may pass multiple quoted strings in one call. If you encounter a bug or change your design, log it immediately. This is the most valuable file for future debugging.
43
+ Each message is automatically prefixed with an ISO timestamp and appended as a bullet to the active `log.md`. One message per call. If you encounter a bug or change your design, log it immediately. This is the most valuable file for future debugging.
44
44
 
45
45
  Only update the currently active logbook entry. Do not edit other existing entries while working.
46
46
 
@@ -11,8 +11,8 @@ Phase 2: Execute & Trace
11
11
  1. Use `log.md` as a live technical scratchpad.
12
12
  2. Log your work in real-time using the `logbook log "<message>"` command. Record every major decision, error, and pivot. Do not reconstruct this at the end.
13
13
  Example: logbook log "Investigated root cause — found issue in src/lib/config.ts"
14
- 3. You may pass multiple messages in one call: logbook log "Msg A" "Msg B"
15
- 4. Stick to the active entry; never modify past entries in the logbook folder.
14
+ One message per call. For multiple entries, call the command once per message.
15
+ 3. Stick to the active entry; never modify past entries in the logbook folder.
16
16
 
17
17
  Phase 3: Synthesize
18
18
  1. When implementation is finished, write the narrative in `index.md`.
@@ -11,6 +11,11 @@ body {
11
11
  padding: 0;
12
12
  }
13
13
 
14
+ .home-link {
15
+ text-decoration: none;
16
+ color: inherit;
17
+ }
18
+
14
19
  header {
15
20
  background: var(--card-bg);
16
21
  border-bottom: 1px solid var(--border);
@@ -25,6 +30,11 @@ header h1 {
25
30
  letter-spacing: -0.025em;
26
31
  }
27
32
 
33
+ header h1 a {
34
+ text-decoration: none !important;
35
+ color: black !important;
36
+ }
37
+
28
38
  .tagline {
29
39
  color: var(--text-muted);
30
40
  font-size: 1.125rem;
@@ -273,6 +283,44 @@ article h1 {
273
283
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
274
284
  }
275
285
 
286
+ /* Pagination Links */
287
+ .pagination-nav {
288
+ display: flex;
289
+ justify-content: space-between;
290
+ margin-top: 2rem;
291
+ padding-top: 1.5rem;
292
+ border-top: 1px solid var(--border);
293
+ }
294
+
295
+ .pagination-link {
296
+ display: inline-flex;
297
+ align-items: center;
298
+ gap: 0.5rem;
299
+ font-size: 0.9375rem;
300
+ font-weight: 500;
301
+ color: var(--primary);
302
+ text-decoration: none;
303
+ padding: 0.5rem 1rem;
304
+ border-radius: 2rem;
305
+ transition:
306
+ background 0.2s,
307
+ color 0.2s;
308
+ background: var(--primary-soft);
309
+ }
310
+
311
+ .pagination-link:hover {
312
+ background: var(--primary);
313
+ color: white;
314
+ }
315
+
316
+ .pagination-link--prev {
317
+ margin-right: auto;
318
+ }
319
+
320
+ .pagination-link--next {
321
+ margin-left: auto;
322
+ }
323
+
276
324
  .content-section {
277
325
  animation: fadeIn 0.4s cubic-bezier(0.4, 0, 0.2, 1);
278
326
  font-size: 1.125rem;
@@ -286,6 +334,10 @@ article h1 {
286
334
  margin-bottom: 1rem;
287
335
  }
288
336
 
337
+ .content-section img {
338
+ max-width: 100%;
339
+ }
340
+
289
341
  @keyframes fadeIn {
290
342
  from {
291
343
  opacity: 0;