@natjswenson/devlog 0.5.2 → 0.8.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.
@@ -0,0 +1,107 @@
1
+ # Cover image style guide
2
+
3
+ This is the fallback style guide devlog uses when composing a post's cover image. It is
4
+ generic. Replace it with your own — the more specific the visual direction, the more
5
+ consistent your covers will look across ~50+ posts.
6
+
7
+ devlog reads this file, plus (when available) the `n` most recently published covers as
8
+ reference images, before composing a new cover. **Actually look at the reference images
9
+ before composing** — the style guide alone under-specifies layout/spacing; the references
10
+ are how a from-scratch visual language stays consistent from post to post.
11
+
12
+ ## What you're composing
13
+
14
+ A single self-contained HTML document (inline `<style>`, inline SVG for any artwork) that
15
+ renders, at exactly 1600×900px, as a cover image for one dev log entry. Compose from these
16
+ fields only — never open the post's raw markdown file, never reference any section other
17
+ than `## Shipped` (no `## Changelog`, no gotchas, no sources):
18
+ - `title`
19
+ - `tags`
20
+ - `summary`
21
+ - the `## Shipped` section's text
22
+
23
+ ## The point of this cover: one custom illustration, not a repeated headline
24
+
25
+ **A cover that just re-renders the title in large text is a failure, no matter how clean
26
+ the typography is.** The title is already the `<h1>` on the post page one scroll down —
27
+ the cover's job is to give the reader something to *look at*, a specific visual idea that
28
+ came from *this* post's content and would look wrong on any other post.
29
+
30
+ Before writing any HTML, do this thinking step explicitly:
31
+ 1. Read the title, summary, and `## Shipped` text.
32
+ 2. Identify the one concrete technical concept or moment the post is actually about —
33
+ not the project name, not "a bug fix," but the specific mechanism: a lock rejecting a
34
+ key that doesn't fit, a git tag being distrusted like any other user input, one broken
35
+ link in a chain, a filter separating signal from noise, a clock catching a stale
36
+ timestamp, two paths diverging and one being cut off.
37
+ 3. Design ONE illustration — built from inline SVG shapes (lines, arcs, polygons,
38
+ simple geometric forms) — that depicts that concept. Not a photo, not a stock icon,
39
+ not a screenshot: a small original line-art scene, in the spirit of an editorial
40
+ illustration or a technical diagram, using only the palette below.
41
+ 4. That illustration is the dominant visual element of the cover — roughly half the
42
+ canvas, not a thumbnail in the corner. Title, kicker, and summary text support it;
43
+ they do not replace it.
44
+
45
+ **Two different posts should never produce visually similar covers.** If your instinct is
46
+ to reach for a generic circle/square/checkmark because you're not sure what else to draw,
47
+ that's a sign to go back to step 2 and find the more specific concept — a post about
48
+ retrying a flaky network call and a post about deduplicating bank transactions should not
49
+ end up with the same shape family.
50
+
51
+ ## Technical requirements (non-negotiable)
52
+
53
+ - Start the document with a literal `<!DOCTYPE html>` declaration, always.
54
+ - `html, body { margin: 0; width: 1600px; height: 900px; }` — the render is a
55
+ viewport-clipped screenshot at exactly this size; content that overflows this box is
56
+ simply never captured, so keep everything inside it.
57
+ - Reference the bundled font only by its fixed name, with a fallback:
58
+ `font-family: 'DevlogCoverFont', sans-serif;` — never embed font bytes yourself, never
59
+ reference any other font file. The renderer injects the real font after your markup is
60
+ parsed.
61
+ - No external resources of any kind — no `<link>`, no `@import`, no remote `<img src>`,
62
+ no web fonts, no raster images. All artwork is inline SVG built from basic shapes
63
+ (`<path>`, `<circle>`, `<rect>`, `<line>`, `<polygon>`, `<polyline>`) — everything must
64
+ be inline HTML/CSS/SVG, hand-composed, not fetched or embedded from anywhere.
65
+
66
+ ## Visual direction
67
+
68
+ The site (natejswenson.com) is a minimalist, monospace, terminal-styled dev log. Covers
69
+ should feel like they belong to the same publication as the site itself — technical
70
+ editorial illustrations, not marketing graphics and not a repeated template:
71
+
72
+ - **Palette:** background `#0a0a0b` (near-black), foreground/line-art color `#ededed`,
73
+ secondary/dim `#8a8a8a`, one accent color `#fff503` (yellow) for the single most
74
+ important element of the illustration — the thing being emphasized, not a decoration.
75
+ Prefer 2-3 colors on a page (black, white, one accent), not a rainbow. Prefer flat,
76
+ limited color and solid/line fills over large smooth gradients — the render is
77
+ compressed with lossy PNG palette quantization afterward, and gradients band visibly
78
+ under that compression while flat fills don't.
79
+ - **Typography:** `'DevlogCoverFont'` (a monospace face) for any on-image text — kicker,
80
+ title, date. Keep the title modest in size (it is not the main event); a short kicker
81
+ (project + date) is enough context. Terminal/code aesthetic glyphs (`$`, `>`, `//`,
82
+ brackets) are fair game as small accents, not as the illustration itself.
83
+ - **Composition:** the illustration occupies the dominant visual weight of the canvas —
84
+ centered or offset to one side, large enough to read at a glance, with the
85
+ title/kicker in the remaining negative space (not overlapping the artwork). Plenty of
86
+ breathing room around the illustration; don't crowd it with text or decoration.
87
+ - **Restraint in execution, not in ambition:** the illustration should be a real, specific
88
+ scene (multiple shapes composed together to depict one concept), not a single
89
+ primitive. But avoid clutter — every shape in the illustration should serve the one
90
+ concept, not decorate around it.
91
+
92
+ ## Never do
93
+
94
+ - Don't make the title the largest, most prominent element on the cover — the
95
+ illustration is.
96
+ - Don't restate the version number as the headline ("v0.6.0" as the big text) — use the
97
+ post's actual title, and keep it secondary to the artwork.
98
+ - Don't reuse the same illustration, shape family, or visual metaphor across different
99
+ posts — go back to the post's actual content and find what's specific to it.
100
+ - Don't fall back to a generic circle/square/checkmark/arrow when stuck — that's the
101
+ exact failure mode this guide exists to prevent. Spend the extra step finding the
102
+ concrete mechanism the post describes.
103
+ - Don't use a gradient as a full-bleed background.
104
+ - Don't embed a photograph, stock image, or anything requiring an external fetch — the
105
+ illustration is drawn from inline SVG primitives, not sourced from anywhere.
106
+ - Don't reference any font other than `'DevlogCoverFont'` (with its `sans-serif`
107
+ fallback).
@@ -3,11 +3,14 @@
3
3
  // reading/writing ~/.claude/skills/devlog/config.json atomically.
4
4
  import { validateConfig, expandHome } from './core.mjs';
5
5
 
6
- export function addProject(config, { key, path, remote, label, tagPrefix, pathFilter }) {
6
+ export function addProject(config, { key, path, remote, label, tagPrefix, pathFilter, private: isPrivate }) {
7
7
  if (config.projects.some((p) => p.key === key)) {
8
8
  throw new Error(`Project key "${key}" is already registered.`);
9
9
  }
10
- const project = { key, path: expandHome(path), remote };
10
+ const project = { key, path: expandHome(path) };
11
+ // remote is required unless the project is private (see core.mjs validateConfig).
12
+ if (remote) project.remote = remote;
13
+ if (isPrivate) project.private = true;
11
14
  if (label) project.label = label;
12
15
  // Only persist tagPrefix when it differs from the default `v` (keeps configs clean).
13
16
  if (tagPrefix && tagPrefix !== 'v') project.tagPrefix = tagPrefix;
package/lib/core.mjs CHANGED
@@ -154,7 +154,18 @@ export function validateConfig(config) {
154
154
  if (typeof p.path !== 'string' || SHELL_QUOTE_BREAK.test(p.path)) {
155
155
  throw new Error(`project.path invalid (must contain no shell metacharacters): ${JSON.stringify(p.path)}`);
156
156
  }
157
- if (!RE_OWNER_REPO.test(p.remote)) {
157
+ if ('private' in p && typeof p.private !== 'boolean') {
158
+ throw new Error(`project.private must be a boolean if present: ${JSON.stringify(p.private)}`);
159
+ }
160
+ // A private project has no safe public commit surface, so `remote` is
161
+ // optional (it's never used to build a commit link — see scan.mjs's
162
+ // isPublic). If supplied anyway, for the maintainer's own reference, it
163
+ // must still be well-formed.
164
+ if (p.private) {
165
+ if ('remote' in p && p.remote !== undefined && !RE_OWNER_REPO.test(p.remote)) {
166
+ throw new Error(`project.remote must match <owner>/<repo>: ${JSON.stringify(p.remote)}`);
167
+ }
168
+ } else if (!RE_OWNER_REPO.test(p.remote)) {
158
169
  throw new Error(`project.remote must match <owner>/<repo>: ${JSON.stringify(p.remote)}`);
159
170
  }
160
171
  if ('pathFilter' in p) {
@@ -0,0 +1,124 @@
1
+ // Style guide + reference-image lookup for cover-image composition. Deterministic,
2
+ // no LLM/agent involvement — the agent calls `devlog cover-context` (bin/devlog.js),
3
+ // which wraps these two functions.
4
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { CONFIG_DIR } from './core.mjs';
7
+
8
+ const IMAGE_STYLE_DIR = join(CONFIG_DIR, 'image-style');
9
+ const STYLE_GUIDE_PATH = join(IMAGE_STYLE_DIR, 'style-guide.md');
10
+
11
+ function slugFromFile(file) {
12
+ return String(file || '').replace(/\.md$/, '');
13
+ }
14
+
15
+ // Pure. Reads the installed style guide. There is no graceful degradation here — Claude
16
+ // has nothing to compose from without it; callers (devlog cover-context) catch the throw
17
+ // and surface it as a distinct error, never blocking the rest of publish.
18
+ export function loadStyleGuide() {
19
+ if (!existsSync(STYLE_GUIDE_PATH)) {
20
+ throw new Error(`Cover style guide not found at ${STYLE_GUIDE_PATH} — run \`devlog init\` to install it.`);
21
+ }
22
+ return readFileSync(STYLE_GUIDE_PATH, 'utf8');
23
+ }
24
+
25
+ // Read one project's manifest.json out of an already-established clone.
26
+ // Two distinct missing-manifest cases (mirrors fetchExistingEntries()'s empty/failed split
27
+ // in lib/scan.mjs): a project directory that doesn't exist at all (never published a
28
+ // release yet) is zero entries, not an error. A project directory that DOES exist but
29
+ // whose manifest.json is missing or fails to parse is a genuine anomaly — surfaced via
30
+ // status: 'failed' so the caller can throw a clear, named error rather than silently
31
+ // dropping that project's entries from a merged result.
32
+ function readProjectManifest(cloneDir, projectKey) {
33
+ const projectDir = join(cloneDir, projectKey);
34
+ if (!existsSync(projectDir)) {
35
+ return { entries: [], status: 'empty' };
36
+ }
37
+ const manifestPath = join(projectDir, 'manifest.json');
38
+ if (!existsSync(manifestPath)) {
39
+ return { entries: [], status: 'failed', reason: `manifest.json missing for project "${projectKey}" (expected at ${manifestPath})` };
40
+ }
41
+ let manifest;
42
+ try {
43
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
44
+ } catch (e) {
45
+ return { entries: [], status: 'failed', reason: `manifest.json for project "${projectKey}" failed to parse: ${e.message}` };
46
+ }
47
+ if (!manifest || !Array.isArray(manifest.entries)) {
48
+ return { entries: [], status: 'failed', reason: `manifest.json for project "${projectKey}" is malformed (expected { "entries": [...] })` };
49
+ }
50
+ return { entries: manifest.entries, status: 'ok' };
51
+ }
52
+
53
+ // Cross-project manifest enumeration/merge — the one mechanism shared by
54
+ // getRecentCovers() (below) and `devlog backfill-covers list` (bin/devlog.js). There is no
55
+ // merged/aggregated manifest file anywhere in daily-dev-log (confirmed: exactly one
56
+ // manifest.json per project directory) — every project's own manifest.json is read
57
+ // individually and tagged with `project` as it's read, mirroring getAllEntries() in
58
+ // natejswenson.io/src/lib/devlog.js (the site's own equivalent per-project-manifest merge;
59
+ // its own empty-vs-corrupt handling is indirect/local-overlay-based, not the same
60
+ // mechanism as this direct directory check, though it reaches the same outcome for the
61
+ // directory-absent case).
62
+ export function mergeManifestEntries(cloneDir, config) {
63
+ const merged = [];
64
+ for (const p of (config.projects || [])) {
65
+ const { entries, status, reason } = readProjectManifest(cloneDir, p.key);
66
+ if (status === 'failed') throw new Error(reason);
67
+ for (const e of entries) merged.push({ ...e, project: p.key });
68
+ }
69
+ return merged;
70
+ }
71
+
72
+ // Staged-but-uncommitted covers from earlier in the same backfill session, freshest first
73
+ // by file mtime. Resolved under stagingDir's per-project subdirectory
74
+ // (<stagingDir>/<project>/<slug>.png) — never a flat <stagingDir>/<slug>.png, since slugs
75
+ // are not globally unique across projects.
76
+ function listStagedCovers(stagingDir) {
77
+ if (!stagingDir || !existsSync(stagingDir)) return [];
78
+ const out = [];
79
+ for (const d of readdirSync(stagingDir, { withFileTypes: true })) {
80
+ if (!d.isDirectory()) continue; // skips the top-level index.html contact sheet
81
+ const projectDir = join(stagingDir, d.name);
82
+ for (const f of readdirSync(projectDir)) {
83
+ if (!f.endsWith('.png')) continue;
84
+ const full = join(projectDir, f);
85
+ out.push({ project: d.name, slug: f.replace(/\.png$/, ''), path: full, mtimeMs: statSync(full).mtimeMs });
86
+ }
87
+ }
88
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs);
89
+ return out.map(({ project, slug, path }) => ({ project, slug, path }));
90
+ }
91
+
92
+ // Pure given its inputs. Returns the N most recently published covers across every
93
+ // configured project (newest by manifest `date`), as local paths inside the already-
94
+ // established clone. When stagingDir is given and fewer than n published covers exist,
95
+ // tops up the result with covers already staged this session (freshest-first) — what lets
96
+ // a from-scratch backfill session bootstrap its own visual consistency instead of every
97
+ // candidate composing from the style guide alone. Returns [] when neither source has
98
+ // anything yet (the true first-cover case) — never throws for that, never pads.
99
+ export function getRecentCovers({ cloneDir, config, stagingDir = null, n = 3 }) {
100
+ const merged = mergeManifestEntries(cloneDir, config);
101
+ const covered = merged
102
+ .filter((e) => e && e.cover && e.file)
103
+ .map((e) => ({ project: e.project, slug: slugFromFile(e.file), date: e.date }))
104
+ .sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
105
+
106
+ const result = covered.slice(0, n).map(({ project, slug }) => ({
107
+ project,
108
+ slug,
109
+ path: join(cloneDir, project, `${slug}.png`),
110
+ }));
111
+
112
+ if (result.length < n) {
113
+ const seen = new Set(result.map((r) => `${r.project}/${r.slug}`));
114
+ for (const staged of listStagedCovers(stagingDir)) {
115
+ if (result.length >= n) break;
116
+ const key = `${staged.project}/${staged.slug}`;
117
+ if (seen.has(key)) continue;
118
+ seen.add(key);
119
+ result.push(staged);
120
+ }
121
+ }
122
+
123
+ return result;
124
+ }
package/lib/lint_post.mjs CHANGED
@@ -10,6 +10,23 @@ export const REQUIRED_SECTIONS = ['Shipped', 'Gotchas', 'Sources'];
10
10
 
11
11
  const RE_DATE = /^\d{4}-\d{2}-\d{2}$/;
12
12
 
13
+ export const TAG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
14
+
15
+ // First-occurrence wins: keeps the first casing seen, drops later
16
+ // case-insensitive duplicates. A same-case repeat is the reachable case in
17
+ // practice — TAG_PATTERN already forbids the case-differing variant outright.
18
+ export function dedupeCaseInsensitive(tags) {
19
+ const seen = new Set();
20
+ const out = [];
21
+ for (const t of tags) {
22
+ const key = t.toLowerCase();
23
+ if (seen.has(key)) continue;
24
+ seen.add(key);
25
+ out.push(t);
26
+ }
27
+ return out;
28
+ }
29
+
13
30
  // Minimal frontmatter parser: `--- ... ---` fence, `key: value` lines, flow
14
31
  // arrays for tags. Prototype-free target object; unknown keys are kept (the
15
32
  // contract does not forbid extras) but only allowlisted keys are checked.
@@ -79,6 +96,12 @@ export function findUntaggedFences(body) {
79
96
  return untagged;
80
97
  }
81
98
 
99
+ // Ignore differences that don't change the destination: trailing slash and
100
+ // URL fragment.
101
+ export function normalizeUrl(url) {
102
+ return url.replace(/#.*$/, '').replace(/\/+$/, '');
103
+ }
104
+
82
105
  export function extractSourceUrls(sectionContent) {
83
106
  const urls = new Set();
84
107
  for (const m of sectionContent.matchAll(/\]\((https?:\/\/[^)\s]+)\)/g)) {
@@ -115,8 +138,18 @@ export function lintPost(content, { minSources = 3, filename = null } = {}) {
115
138
  add('title-style', 'Title must be essay-style, not a "release vX.Y.Z" label.');
116
139
  }
117
140
  }
118
- if (Array.isArray(data.tags) && (data.tags.length < 2 || data.tags.length > 5)) {
119
- add('tags-count', `Expected 2-5 topic tags, got ${data.tags.length}.`);
141
+ if (Array.isArray(data.tags) && (data.tags.length < 5 || data.tags.length > 10)) {
142
+ add('tags-count', `Expected 5-10 topic tags, got ${data.tags.length}.`);
143
+ }
144
+ if (Array.isArray(data.tags)) {
145
+ for (const t of data.tags) {
146
+ if (typeof t === 'string' && !TAG_PATTERN.test(t)) {
147
+ add('tags-character-pattern', `Tag "${t}" must be lowercase alphanumeric/hyphens only (^[a-z0-9][a-z0-9-]*$).`);
148
+ }
149
+ }
150
+ if (dedupeCaseInsensitive(data.tags).length !== data.tags.length) {
151
+ add('tags-duplicate', 'Tags contain a case-insensitive duplicate.');
152
+ }
120
153
  }
121
154
  if (filename && typeof data.version === 'string' && data.version) {
122
155
  const expected = `${data.version}.md`;
@@ -142,6 +175,19 @@ export function lintPost(content, { minSources = 3, filename = null } = {}) {
142
175
  if (urls.size < minSources) {
143
176
  add('sources-count', `Need at least ${minSources} distinct source URLs; found ${urls.size}.`);
144
177
  }
178
+ // The contract requires claims to carry their citation where they're made,
179
+ // not only in the bibliography: every Sources URL must also be cited
180
+ // inline somewhere else in the body.
181
+ const inline = new Set();
182
+ for (const s of sections) {
183
+ if (s.heading === 'Sources') continue;
184
+ for (const u of extractSourceUrls(s.content)) inline.add(normalizeUrl(u));
185
+ }
186
+ for (const u of urls) {
187
+ if (!inline.has(normalizeUrl(u))) {
188
+ add('sources-inline', `Source ${u} is listed in \`## Sources\` but never cited inline in the body — cite it where its claim is made, or drop it from Sources.`);
189
+ }
190
+ }
145
191
  }
146
192
 
147
193
  for (const line of findUntaggedFences(body)) {
@@ -2,11 +2,33 @@
2
2
  // manifest. This is the code-enforced immutability guard: a cut release's
3
3
  // entry is never overwritten, and manifest mutation is no longer done by
4
4
  // hand-editing JSON in the agent loop.
5
- import { existsSync, mkdirSync, readFileSync, copyFileSync } from 'node:fs';
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, statSync, openSync, readSync, closeSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
7
  import { RE_PROJECT_KEY, RE_FINAL_RELEASE, atomicWriteJSON } from './core.mjs';
8
8
  import { parseFrontmatter } from './lint_post.mjs';
9
9
 
10
+ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
11
+
12
+ function isValidPng(path) {
13
+ let fd;
14
+ try {
15
+ fd = openSync(path, 'r');
16
+ const buf = Buffer.alloc(8);
17
+ const n = readSync(fd, buf, 0, 8, 0);
18
+ return n === 8 && buf.equals(PNG_MAGIC);
19
+ } catch {
20
+ return false;
21
+ } finally {
22
+ if (fd !== undefined) closeSync(fd);
23
+ }
24
+ }
25
+
26
+ function assertSafeSlug(slug) {
27
+ if (typeof slug !== 'string' || slug === '' || slug.includes('/') || slug.includes('..') || /[\x00-\x1f]/.test(slug)) {
28
+ throw new Error(`Invalid slug: ${JSON.stringify(slug)}`);
29
+ }
30
+ }
31
+
10
32
  // Newest-first by date; same-date ties break by version, highest first.
11
33
  // Without the tiebreak, several releases cut on one day render oldest-on-top
12
34
  // in the feed (stable sort keeps insertion order), burying the newest post
@@ -33,7 +55,7 @@ function sortEntries(entries) {
33
55
  String(b.date).localeCompare(String(a.date)) || compareVersionsDesc(a, b));
34
56
  }
35
57
 
36
- export function publishEntry({ cloneDir, project, version, entryPath }) {
58
+ export function publishEntry({ cloneDir, project, version, entryPath, coverImageBuffer }) {
37
59
  if (!RE_PROJECT_KEY.test(project) || project.includes('..')) {
38
60
  throw new Error(`Invalid project key: ${JSON.stringify(project)}`);
39
61
  }
@@ -58,6 +80,17 @@ export function publishEntry({ cloneDir, project, version, entryPath }) {
58
80
  mkdirSync(projectDir, { recursive: true });
59
81
  copyFileSync(entryPath, destPath);
60
82
 
83
+ // Cover write happens between the .md write and the manifest mutation, matching the
84
+ // existing .md-then-manifest crash-recovery convention: a process death after this write
85
+ // but before the manifest mutation leaves an orphaned <version>.png with no matching
86
+ // `cover` field — harmless inert clutter (the manifest is the sole source of truth for
87
+ // "does this post have a cover"), not a correctness bug, and needs no cleanup logic.
88
+ let coverFile = null;
89
+ if (coverImageBuffer) {
90
+ coverFile = `${version}.png`;
91
+ writeFileSync(join(projectDir, coverFile), coverImageBuffer);
92
+ }
93
+
61
94
  const manifestPath = join(projectDir, 'manifest.json');
62
95
  let manifest = { entries: [] };
63
96
  if (existsSync(manifestPath)) {
@@ -79,11 +112,90 @@ export function publishEntry({ cloneDir, project, version, entryPath }) {
79
112
  title: String(data.title),
80
113
  summary: String(data.summary),
81
114
  version,
115
+ tags: Array.isArray(data.tags) ? data.tags : [],
116
+ ...(coverFile ? { cover: { file: coverFile, bytes: coverImageBuffer.length } } : {}),
82
117
  });
83
118
  manifest.entries = sortEntries(manifest.entries);
84
119
  atomicWriteJSON(manifestPath, manifest);
85
120
  manifestUpdated = true;
86
121
  }
87
122
 
88
- return { written: destPath, manifestUpdated };
123
+ return { written: destPath, manifestUpdated, coverWritten: !!coverFile };
124
+ }
125
+
126
+ // Backfill path only: add a cover to an entry that was already published without one.
127
+ // Never writes/reads <slug>.md, never pushes a new manifest row — its only mutation is the
128
+ // `cover` field of an already-existing entry, keyed by that entry's version/file stem.
129
+ //
130
+ // Three-way branch, in order:
131
+ // 1. force: true -> ALWAYS overwrite the clone-destination PNG unconditionally,
132
+ // no magic-byte check, no adoption logic. This is what
133
+ // "force" means: --force is used precisely when a row
134
+ // already has `cover`, so an ungated adoption check would
135
+ // otherwise silently skip the write exactly when the caller
136
+ // most clearly intends to overwrite.
137
+ // 2. !force, row has cover -> throw/refuse before any write.
138
+ // 3. !force, row lacks cover -> the only branch where magic-byte adopt-or-discard logic
139
+ // applies. Kept as cheap, harmless insurance for a resume
140
+ // scenario that is NOT reachable via commit-covers's actual
141
+ // call pattern (commit-covers always establishes a fresh
142
+ // clone and performs exactly one commit+push at the very
143
+ // end, so a crash mid-run never leaves this orphan
144
+ // discoverable by a later invocation) — not a claim that
145
+ // this state occurs in practice.
146
+ export function addCoverToExistingEntry({ cloneDir, project, slug, coverImageBuffer, force = false }) {
147
+ if (!RE_PROJECT_KEY.test(project) || project.includes('..')) {
148
+ throw new Error(`Invalid project key: ${JSON.stringify(project)}`);
149
+ }
150
+ assertSafeSlug(slug);
151
+ if (!coverImageBuffer || !Buffer.isBuffer(coverImageBuffer)) {
152
+ throw new Error('coverImageBuffer is required and must be a Buffer');
153
+ }
154
+ if (!existsSync(cloneDir)) throw new Error(`Clone directory not found: ${cloneDir}`);
155
+
156
+ const projectDir = join(cloneDir, project);
157
+ const manifestPath = join(projectDir, 'manifest.json');
158
+ if (!existsSync(manifestPath)) {
159
+ throw new Error(`No manifest found for project "${project}" at ${manifestPath}`);
160
+ }
161
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
162
+ if (!manifest || !Array.isArray(manifest.entries)) {
163
+ throw new Error(`Malformed manifest at ${manifestPath}: expected { "entries": [...] }.`);
164
+ }
165
+
166
+ const idx = manifest.entries.findIndex(
167
+ (e) => e && (e.version === slug || (e.file && e.file.replace(/\.md$/, '') === slug))
168
+ );
169
+ if (idx === -1) {
170
+ throw new Error(`No manifest row for ${project}/${slug} — cannot add a cover to an entry that doesn't exist.`);
171
+ }
172
+ const entry = manifest.entries[idx];
173
+
174
+ if (entry.cover && !force) {
175
+ throw new Error(`${project}/${slug} already has a cover — pass force: true to overwrite.`);
176
+ }
177
+
178
+ const coverFile = `${slug}.png`;
179
+ const destPath = join(projectDir, coverFile);
180
+ mkdirSync(projectDir, { recursive: true });
181
+
182
+ let written;
183
+ if (force) {
184
+ writeFileSync(destPath, coverImageBuffer);
185
+ written = destPath;
186
+ } else if (existsSync(destPath) && isValidPng(destPath)) {
187
+ // Adopt the existing file as the completed result of a hypothetical interrupted prior
188
+ // write. coverImageBuffer (the staging-dir source the caller already read — a
189
+ // different file from this clone-destination path) is NOT rewritten over it.
190
+ written = destPath;
191
+ } else {
192
+ writeFileSync(destPath, coverImageBuffer);
193
+ written = destPath;
194
+ }
195
+
196
+ const bytes = statSync(written).size;
197
+ manifest.entries[idx] = { ...entry, cover: { file: coverFile, bytes } };
198
+ atomicWriteJSON(manifestPath, manifest);
199
+
200
+ return { written, manifestUpdated: true };
89
201
  }
@@ -0,0 +1,156 @@
1
+ // Rasterize a Claude-composed HTML/CSS cover into a fixed-size PNG.
2
+ //
3
+ // This is the one genuinely deterministic, testable function in the cover-generation
4
+ // path — composing the HTML itself is agent behavior, not a library call (see SKILL.md
5
+ // Step 5 / devlog cover-context). Uses headless Chromium (the `playwright` package,
6
+ // never `playwright-core` — the CLI needs the full package so
7
+ // `npx playwright install chromium` works).
8
+ import { chromium } from 'playwright';
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { homedir } from 'node:os';
12
+ import sharp from 'sharp';
13
+
14
+ export const COVER_FONT_FAMILY = 'DevlogCoverFont';
15
+ export const DEFAULT_RENDER_TIMEOUT_MS = 15000;
16
+ const FONT_PATH = join(homedir(), '.claude', 'skills', 'devlog', 'image-style', 'font.ttf');
17
+ const QUANTIZE_TARGET_BYTES = 500 * 1024;
18
+
19
+ // Deterministic Node code, never agent-authored text: reads the installed font file and
20
+ // builds a base64 data URI. The font's bytes never pass through Claude's own text
21
+ // generation — a qualitatively different (and much less reliable, at this size) operation
22
+ // than Claude directly viewing reference cover images.
23
+ function readFontBase64(fontPath) {
24
+ if (!existsSync(fontPath)) {
25
+ throw new Error(`Cover font not found at ${fontPath} — run \`devlog init\` to install it.`);
26
+ }
27
+ let bytes;
28
+ try {
29
+ bytes = readFileSync(fontPath);
30
+ } catch (e) {
31
+ throw new Error(`Cover font at ${fontPath} could not be read: ${e.message}`);
32
+ }
33
+ if (bytes.length === 0) {
34
+ throw new Error(`Cover font at ${fontPath} is empty (0 bytes) — reinstall it with \`devlog init\`.`);
35
+ }
36
+ return bytes.toString('base64');
37
+ }
38
+
39
+ // Lossy palette quantization toward a ~300-500KB target. Best-effort: never throws for
40
+ // size reasons, just returns the smallest of the attempts tried. Quantization affects
41
+ // color depth/file size only, never pixel dimensions.
42
+ async function quantize(pngBuffer) {
43
+ const attempts = [
44
+ { palette: true, quality: 90, effort: 8 },
45
+ { palette: true, quality: 70, colors: 128, effort: 8 },
46
+ { palette: true, quality: 50, colors: 64, effort: 8 },
47
+ ];
48
+ let best = pngBuffer;
49
+ for (const opts of attempts) {
50
+ let out;
51
+ try {
52
+ out = await sharp(pngBuffer).png(opts).toBuffer();
53
+ } catch {
54
+ continue; // this attempt's options weren't accepted; fall through to the next
55
+ }
56
+ if (out.length < best.length) best = out;
57
+ if (out.length <= QUANTIZE_TARGET_BYTES) return out;
58
+ }
59
+ return best;
60
+ }
61
+
62
+ /**
63
+ * @param {string} html full, self-contained HTML document (must start with <!DOCTYPE html>)
64
+ * @param {{width:number, height:number, timeoutMs?:number, fontPath?:string, executablePath?:string}} opts
65
+ * @returns {Promise<Buffer>} PNG bytes, exactly {width}x{height} pixels
66
+ *
67
+ * Throws on exactly three realistic failure modes: a render timeout; Chromium not being
68
+ * installed; a missing/unreadable installed font file. Does NOT throw on malformed HTML —
69
+ * Chromium's HTML5 parser is deliberately fault-tolerant and recovers into some DOM
70
+ * regardless of input; a poorly composed document renders wrong, it doesn't fail to render.
71
+ */
72
+ export async function renderCoverImage(html, opts = {}) {
73
+ const {
74
+ width,
75
+ height,
76
+ timeoutMs = DEFAULT_RENDER_TIMEOUT_MS,
77
+ fontPath = FONT_PATH,
78
+ executablePath,
79
+ // Test-only seam, not part of the documented contract: lets tests inject a fake
80
+ // launch() to spy on page.setContent/addStyleTag/evaluate/screenshot call order
81
+ // without spinning up real Chromium. Defaults to the real playwright launcher.
82
+ launch = executablePath ? (o) => chromium.launch({ ...o, executablePath }) : (o) => chromium.launch(o),
83
+ } = opts;
84
+
85
+ if (typeof html !== 'string' || html.trim() === '') {
86
+ throw new Error('renderCoverImage: html must be a non-empty string');
87
+ }
88
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
89
+ throw new Error('renderCoverImage: width/height must be positive integers');
90
+ }
91
+
92
+ // Read + validate the font BEFORE ever launching Chromium — a missing/corrupt font is a
93
+ // plain, cheap filesystem check and shouldn't cost a browser launch to detect.
94
+ const fontBase64 = readFontBase64(fontPath);
95
+
96
+ let browser;
97
+ try {
98
+ browser = await launch(undefined);
99
+ } catch (e) {
100
+ throw new Error(
101
+ `Chromium is not installed (or failed to launch) — run \`npx playwright install chromium\`. (${e.message})`
102
+ );
103
+ }
104
+
105
+ try {
106
+ const page = await browser.newPage({ viewport: { width, height } });
107
+
108
+ // Parse Claude's document exactly as authored first, in standards mode — never a raw
109
+ // string prepend of the font-face rule, which would force quirks mode (per the HTML5
110
+ // tree-construction algorithm, any non-whitespace content before the DOCTYPE token
111
+ // does) and risks a same-specificity shadow from Claude's own CSS.
112
+ await page.setContent(html, { waitUntil: 'load', timeout: timeoutMs });
113
+
114
+ // Inject the real font into the already-parsed document via the DOM API. Landing here
115
+ // — added after the page's own stylesheets are already parsed — also means this rule
116
+ // wins any cascade tie against markup referencing the same family, by document order.
117
+ const fontFaceCss =
118
+ `@font-face { font-family: '${COVER_FONT_FAMILY}'; ` +
119
+ `src: url(data:font/ttf;base64,${fontBase64}) format('truetype'); }`;
120
+ await page.addStyleTag({ content: fontFaceCss });
121
+
122
+ // Merely declaring @font-face does not synchronously start the load — Chromium only
123
+ // triggers the fetch/decode once a style-recalc discovers text resolving to that
124
+ // family, and that recalc isn't guaranteed to have run yet. document.fonts.load()
125
+ // explicitly kicks off the load so the immediately-following document.fonts.ready
126
+ // check is guaranteed to cover it, closing a real race where the ready-promise could
127
+ // otherwise resolve before the embedded font has actually finished decoding.
128
+ const fontsReady = (async () => {
129
+ await page.evaluate((family) => document.fonts.load(`1em '${family}'`), COVER_FONT_FAMILY);
130
+ await page.evaluate(() => document.fonts.ready);
131
+ })();
132
+ // Attach a no-op handler immediately so a late rejection (e.g. the timeout branch below
133
+ // wins the race, then this promise itself rejects after the page is torn down) never
134
+ // surfaces as an unhandled rejection — the race below is still driven by this same
135
+ // promise reference.
136
+ fontsReady.catch(() => {});
137
+ await Promise.race([
138
+ fontsReady,
139
+ new Promise((_, reject) =>
140
+ setTimeout(
141
+ () => reject(new Error(`renderCoverImage: timed out after ${timeoutMs}ms waiting on font load`)),
142
+ timeoutMs
143
+ )
144
+ ),
145
+ ]);
146
+
147
+ // Viewport-clipped screenshot (fullPage omitted/false, Playwright's default) — never
148
+ // fullPage: true, which would capture the whole scrollable page rather than just the
149
+ // viewport. This is what guarantees the output is always exactly {width, height}
150
+ // regardless of whether the composed HTML's content overflows it.
151
+ const png = await page.screenshot({ type: 'png', timeout: timeoutMs });
152
+ return await quantize(png);
153
+ } finally {
154
+ await browser.close();
155
+ }
156
+ }