@natjswenson/devlog 0.6.0 → 0.9.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/SKILL.md +63 -3
- package/bin/devlog.js +454 -7
- package/config.example.json +6 -0
- package/evals/fixtures/good-post.md +1 -1
- package/evals/fixtures/irreproducible-post.md +1 -1
- package/image-style/font.ttf +0 -0
- package/image-style/icons.md +234 -0
- package/image-style/style-guide.example.md +138 -0
- package/lib/config_ops.mjs +5 -2
- package/lib/core.mjs +12 -1
- package/lib/cover_gen.mjs +137 -0
- package/lib/lint_post.mjs +29 -2
- package/lib/publish_entry.mjs +115 -3
- package/lib/render_cover.mjs +251 -0
- package/lib/scan.mjs +6 -0
- package/package.json +4 -1
- package/skill-invariants.json +35 -2
package/lib/publish_entry.mjs
CHANGED
|
@@ -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,251 @@
|
|
|
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
|
+
// Fixed hero-zone bounding box on the 1600x900 canvas — the single source of truth this
|
|
20
|
+
// design's prose (image-style/style-guide.example.md) must state identically, checked by
|
|
21
|
+
// tests/skill_contract.test.mjs's COVER-Q-2 invariant rather than trusted to manual review.
|
|
22
|
+
export const HERO_ZONE = { x: 150, y: 425, width: 1300, height: 400 };
|
|
23
|
+
export const HERO_GRID_UNIT = 25;
|
|
24
|
+
// getBoundingClientRect() subpixel/rounding tolerance — not a meaningful size/position
|
|
25
|
+
// allowance. Exact-match (within this tolerance), never containment: a containment check
|
|
26
|
+
// would let an agent draw a tiny #hero-zone in a corner and trivially clear the
|
|
27
|
+
// catalog-overlap check below, since a tiny box is still "contained" in the larger one.
|
|
28
|
+
const HERO_ZONE_TOLERANCE_PX = 2;
|
|
29
|
+
|
|
30
|
+
// Deterministic Node code, never agent-authored text: reads the installed font file and
|
|
31
|
+
// builds a base64 data URI. The font's bytes never pass through Claude's own text
|
|
32
|
+
// generation — a qualitatively different (and much less reliable, at this size) operation
|
|
33
|
+
// than Claude directly viewing reference cover images.
|
|
34
|
+
function readFontBase64(fontPath) {
|
|
35
|
+
if (!existsSync(fontPath)) {
|
|
36
|
+
throw new Error(`Cover font not found at ${fontPath} — run \`devlog init\` to install it.`);
|
|
37
|
+
}
|
|
38
|
+
let bytes;
|
|
39
|
+
try {
|
|
40
|
+
bytes = readFileSync(fontPath);
|
|
41
|
+
} catch (e) {
|
|
42
|
+
throw new Error(`Cover font at ${fontPath} could not be read: ${e.message}`);
|
|
43
|
+
}
|
|
44
|
+
if (bytes.length === 0) {
|
|
45
|
+
throw new Error(`Cover font at ${fontPath} is empty (0 bytes) — reinstall it with \`devlog init\`.`);
|
|
46
|
+
}
|
|
47
|
+
return bytes.toString('base64');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Lossy palette quantization toward a ~300-500KB target. Best-effort: never throws for
|
|
51
|
+
// size reasons, just returns the smallest of the attempts tried. Quantization affects
|
|
52
|
+
// color depth/file size only, never pixel dimensions.
|
|
53
|
+
async function quantize(pngBuffer) {
|
|
54
|
+
const attempts = [
|
|
55
|
+
{ palette: true, quality: 90, effort: 8 },
|
|
56
|
+
{ palette: true, quality: 70, colors: 128, effort: 8 },
|
|
57
|
+
{ palette: true, quality: 50, colors: 64, effort: 8 },
|
|
58
|
+
];
|
|
59
|
+
let best = pngBuffer;
|
|
60
|
+
for (const opts of attempts) {
|
|
61
|
+
let out;
|
|
62
|
+
try {
|
|
63
|
+
out = await sharp(pngBuffer).png(opts).toBuffer();
|
|
64
|
+
} catch {
|
|
65
|
+
continue; // this attempt's options weren't accepted; fall through to the next
|
|
66
|
+
}
|
|
67
|
+
if (out.length < best.length) best = out;
|
|
68
|
+
if (out.length <= QUANTIZE_TARGET_BYTES) return out;
|
|
69
|
+
}
|
|
70
|
+
return best;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Two rects overlap only on positive-area intersection — rects that merely touch along an
|
|
74
|
+
// edge (zero-area overlap) do NOT count as intersecting.
|
|
75
|
+
function rectsOverlap(a, b) {
|
|
76
|
+
const left = Math.max(a.x, b.x);
|
|
77
|
+
const right = Math.min(a.x + a.width, b.x + b.width);
|
|
78
|
+
const top = Math.max(a.y, b.y);
|
|
79
|
+
const bottom = Math.min(a.y + a.height, b.y + b.height);
|
|
80
|
+
return right > left && bottom > top;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function withinTolerance(rect, fixed, toleranceExclusivePx) {
|
|
84
|
+
return (
|
|
85
|
+
Math.abs(rect.x - fixed.x) <= toleranceExclusivePx &&
|
|
86
|
+
Math.abs(rect.y - fixed.y) <= toleranceExclusivePx &&
|
|
87
|
+
Math.abs(rect.width - fixed.width) <= toleranceExclusivePx &&
|
|
88
|
+
Math.abs(rect.height - fixed.height) <= toleranceExclusivePx
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {import('playwright').Page} page
|
|
94
|
+
* @returns {Promise<{overlaps: boolean, offendingIcons: string[]}>}
|
|
95
|
+
*
|
|
96
|
+
* #hero-zone is structurally mandatory, not an opt-in marker: throws if querySelectorAll
|
|
97
|
+
* finds zero elements (missing) or more than one (duplicate) — rather than silently
|
|
98
|
+
* skipping the check or resolving to the first DOM match. Once exactly one #hero-zone is
|
|
99
|
+
* confirmed, its rect is compared against the fixed HERO_ZONE constant (exact-match within
|
|
100
|
+
* HERO_ZONE_TOLERANCE_PX, not containment — see the constant's own comment) and throws a
|
|
101
|
+
* distinct geometry-mismatch error if it's outside tolerance, BEFORE computing catalog-icon
|
|
102
|
+
* overlap. Only past both of those checks does this function resolve normally to
|
|
103
|
+
* { overlaps, offendingIcons } — overlap-found and overlap-not-found are both successful
|
|
104
|
+
* resolutions of the check; it is the caller (renderCoverImage) that decides whether
|
|
105
|
+
* overlaps: true itself becomes a thrown error.
|
|
106
|
+
*/
|
|
107
|
+
export async function checkHeroZoneOverlap(page) {
|
|
108
|
+
const heroZoneRects = await page.evaluate(() =>
|
|
109
|
+
[...document.querySelectorAll('#hero-zone')].map((el) => {
|
|
110
|
+
const r = el.getBoundingClientRect();
|
|
111
|
+
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
112
|
+
})
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
if (heroZoneRects.length === 0) {
|
|
116
|
+
throw new Error('renderCoverImage: composed HTML has no #hero-zone element — a hero zone marker is required, not optional.');
|
|
117
|
+
}
|
|
118
|
+
if (heroZoneRects.length > 1) {
|
|
119
|
+
throw new Error(`renderCoverImage: composed HTML has ${heroZoneRects.length} elements sharing the #hero-zone id — exactly one is required.`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const heroZoneRect = heroZoneRects[0];
|
|
123
|
+
if (!withinTolerance(heroZoneRect, HERO_ZONE, HERO_ZONE_TOLERANCE_PX)) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`renderCoverImage: #hero-zone rect (x:${heroZoneRect.x} y:${heroZoneRect.y} width:${heroZoneRect.width} height:${heroZoneRect.height}) ` +
|
|
126
|
+
`does not match the fixed HERO_ZONE box (x:${HERO_ZONE.x} y:${HERO_ZONE.y} width:${HERO_ZONE.width} height:${HERO_ZONE.height}) ` +
|
|
127
|
+
`within ${HERO_ZONE_TOLERANCE_PX}px tolerance.`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const iconRects = await page.evaluate(() =>
|
|
132
|
+
[...document.querySelectorAll('[data-catalog-icon]')].map((el) => {
|
|
133
|
+
const r = el.getBoundingClientRect();
|
|
134
|
+
return { name: el.getAttribute('data-catalog-icon'), x: r.x, y: r.y, width: r.width, height: r.height };
|
|
135
|
+
})
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const offendingIcons = iconRects.filter((icon) => rectsOverlap(icon, heroZoneRect)).map((icon) => icon.name);
|
|
139
|
+
return { overlaps: offendingIcons.length > 0, offendingIcons };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @param {string} html full, self-contained HTML document (must start with <!DOCTYPE html>)
|
|
144
|
+
* @param {{width:number, height:number, timeoutMs?:number, fontPath?:string, executablePath?:string}} opts
|
|
145
|
+
* @returns {Promise<Buffer>} PNG bytes, exactly {width}x{height} pixels
|
|
146
|
+
*
|
|
147
|
+
* Throws on exactly four realistic failure modes: a render timeout; Chromium not being
|
|
148
|
+
* installed; a missing/unreadable installed font file; and (a deliberate widening of this
|
|
149
|
+
* already-documented throw contract) a #hero-zone structural/geometry problem — missing
|
|
150
|
+
* #hero-zone, duplicate #hero-zone, the #hero-zone rect not matching the fixed HERO_ZONE
|
|
151
|
+
* bounding box within tolerance, or a catalog icon overlapping the hero zone. Does NOT
|
|
152
|
+
* throw on malformed HTML — Chromium's HTML5 parser is deliberately fault-tolerant and
|
|
153
|
+
* recovers into some DOM regardless of input; a poorly composed document renders wrong, it
|
|
154
|
+
* doesn't fail to render.
|
|
155
|
+
*/
|
|
156
|
+
export async function renderCoverImage(html, opts = {}) {
|
|
157
|
+
const {
|
|
158
|
+
width,
|
|
159
|
+
height,
|
|
160
|
+
timeoutMs = DEFAULT_RENDER_TIMEOUT_MS,
|
|
161
|
+
fontPath = FONT_PATH,
|
|
162
|
+
executablePath,
|
|
163
|
+
// Test-only seam, not part of the documented contract: lets tests inject a fake
|
|
164
|
+
// launch() to spy on page.setContent/addStyleTag/evaluate/screenshot call order
|
|
165
|
+
// without spinning up real Chromium. Defaults to the real playwright launcher.
|
|
166
|
+
launch = executablePath ? (o) => chromium.launch({ ...o, executablePath }) : (o) => chromium.launch(o),
|
|
167
|
+
} = opts;
|
|
168
|
+
|
|
169
|
+
if (typeof html !== 'string' || html.trim() === '') {
|
|
170
|
+
throw new Error('renderCoverImage: html must be a non-empty string');
|
|
171
|
+
}
|
|
172
|
+
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
|
|
173
|
+
throw new Error('renderCoverImage: width/height must be positive integers');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Read + validate the font BEFORE ever launching Chromium — a missing/corrupt font is a
|
|
177
|
+
// plain, cheap filesystem check and shouldn't cost a browser launch to detect.
|
|
178
|
+
const fontBase64 = readFontBase64(fontPath);
|
|
179
|
+
|
|
180
|
+
let browser;
|
|
181
|
+
try {
|
|
182
|
+
browser = await launch(undefined);
|
|
183
|
+
} catch (e) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`Chromium is not installed (or failed to launch) — run \`npx playwright install chromium\`. (${e.message})`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const page = await browser.newPage({ viewport: { width, height } });
|
|
191
|
+
|
|
192
|
+
// Parse Claude's document exactly as authored first, in standards mode — never a raw
|
|
193
|
+
// string prepend of the font-face rule, which would force quirks mode (per the HTML5
|
|
194
|
+
// tree-construction algorithm, any non-whitespace content before the DOCTYPE token
|
|
195
|
+
// does) and risks a same-specificity shadow from Claude's own CSS.
|
|
196
|
+
await page.setContent(html, { waitUntil: 'load', timeout: timeoutMs });
|
|
197
|
+
|
|
198
|
+
// Inject the real font into the already-parsed document via the DOM API. Landing here
|
|
199
|
+
// — added after the page's own stylesheets are already parsed — also means this rule
|
|
200
|
+
// wins any cascade tie against markup referencing the same family, by document order.
|
|
201
|
+
const fontFaceCss =
|
|
202
|
+
`@font-face { font-family: '${COVER_FONT_FAMILY}'; ` +
|
|
203
|
+
`src: url(data:font/ttf;base64,${fontBase64}) format('truetype'); }`;
|
|
204
|
+
await page.addStyleTag({ content: fontFaceCss });
|
|
205
|
+
|
|
206
|
+
// Merely declaring @font-face does not synchronously start the load — Chromium only
|
|
207
|
+
// triggers the fetch/decode once a style-recalc discovers text resolving to that
|
|
208
|
+
// family, and that recalc isn't guaranteed to have run yet. document.fonts.load()
|
|
209
|
+
// explicitly kicks off the load so the immediately-following document.fonts.ready
|
|
210
|
+
// check is guaranteed to cover it, closing a real race where the ready-promise could
|
|
211
|
+
// otherwise resolve before the embedded font has actually finished decoding.
|
|
212
|
+
const fontsReady = (async () => {
|
|
213
|
+
await page.evaluate((family) => document.fonts.load(`1em '${family}'`), COVER_FONT_FAMILY);
|
|
214
|
+
await page.evaluate(() => document.fonts.ready);
|
|
215
|
+
})();
|
|
216
|
+
// Attach a no-op handler immediately so a late rejection (e.g. the timeout branch below
|
|
217
|
+
// wins the race, then this promise itself rejects after the page is torn down) never
|
|
218
|
+
// surfaces as an unhandled rejection — the race below is still driven by this same
|
|
219
|
+
// promise reference.
|
|
220
|
+
fontsReady.catch(() => {});
|
|
221
|
+
await Promise.race([
|
|
222
|
+
fontsReady,
|
|
223
|
+
new Promise((_, reject) =>
|
|
224
|
+
setTimeout(
|
|
225
|
+
() => reject(new Error(`renderCoverImage: timed out after ${timeoutMs}ms waiting on font load`)),
|
|
226
|
+
timeoutMs
|
|
227
|
+
)
|
|
228
|
+
),
|
|
229
|
+
]);
|
|
230
|
+
|
|
231
|
+
// Geometry-enforced hero-zone guard, immediately before the screenshot: throws on a
|
|
232
|
+
// missing/duplicate #hero-zone, a #hero-zone rect that doesn't match the fixed
|
|
233
|
+
// HERO_ZONE box, or (below) a catalog icon whose rect overlaps the hero zone.
|
|
234
|
+
const { overlaps, offendingIcons } = await checkHeroZoneOverlap(page);
|
|
235
|
+
if (overlaps) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
`renderCoverImage: catalog icon(s) [${offendingIcons.join(', ')}] overlaps hero zone — ` +
|
|
238
|
+
'catalog icons may only appear as an accent glyph outside #hero-zone, never inside it.'
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Viewport-clipped screenshot (fullPage omitted/false, Playwright's default) — never
|
|
243
|
+
// fullPage: true, which would capture the whole scrollable page rather than just the
|
|
244
|
+
// viewport. This is what guarantees the output is always exactly {width, height}
|
|
245
|
+
// regardless of whether the composed HTML's content overflows it.
|
|
246
|
+
const png = await page.screenshot({ type: 'png', timeout: timeoutMs });
|
|
247
|
+
return await quantize(png);
|
|
248
|
+
} finally {
|
|
249
|
+
await browser.close();
|
|
250
|
+
}
|
|
251
|
+
}
|
package/lib/scan.mjs
CHANGED
|
@@ -74,6 +74,7 @@ export function scanProject(project, { branch = 'main', fetch = true, existingFi
|
|
|
74
74
|
key: project.key,
|
|
75
75
|
label: project.label || project.key,
|
|
76
76
|
remote: project.remote,
|
|
77
|
+
private: !!project.private,
|
|
77
78
|
path: project.path,
|
|
78
79
|
pathFilter: project.pathFilter || null,
|
|
79
80
|
tagPrefix: project.tagPrefix || 'v',
|
|
@@ -107,6 +108,11 @@ export function scanProject(project, { branch = 'main', fetch = true, existingFi
|
|
|
107
108
|
const hasPublishedRef = git(project.path, ['rev-parse', '--verify', '--quiet', publishedRef]) !== null;
|
|
108
109
|
|
|
109
110
|
const isPublic = (rev) => {
|
|
111
|
+
// A private project has no safe commit surface, full stop — this bypasses
|
|
112
|
+
// remoteMatches/hasPublishedRef entirely rather than relying on them to
|
|
113
|
+
// happen to be false, since a private repo can still have origin configured
|
|
114
|
+
// correctly (remoteMatches true) and a normally-pushed branch.
|
|
115
|
+
if (project.private) return false;
|
|
110
116
|
if (!remoteMatches || !hasPublishedRef) return false;
|
|
111
117
|
return spawnArgs('git', ['-C', project.path, 'merge-base', '--is-ancestor', rev, publishedRef]).status === 0;
|
|
112
118
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@natjswenson/devlog",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Release dev log generator \u2014 Claude Code skill + preview app for publishing version-release dev logs, written in your voice, to your site",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Nate Swenson",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"preview/",
|
|
34
34
|
"examples/",
|
|
35
35
|
"voice/",
|
|
36
|
+
"image-style/",
|
|
36
37
|
"SKILL.md",
|
|
37
38
|
"SECURITY.md",
|
|
38
39
|
"CHANGELOG.md",
|
|
@@ -50,11 +51,13 @@
|
|
|
50
51
|
"dependencies": {
|
|
51
52
|
"@vitejs/plugin-react": "6.0.1",
|
|
52
53
|
"kleur": "4.1.5",
|
|
54
|
+
"playwright": "1.61.1",
|
|
53
55
|
"prompts": "2.4.2",
|
|
54
56
|
"react": "18.3.1",
|
|
55
57
|
"react-dom": "18.3.1",
|
|
56
58
|
"react-markdown": "9.1.0",
|
|
57
59
|
"remark-gfm": "4.0.1",
|
|
60
|
+
"sharp": "0.35.3",
|
|
58
61
|
"vite": "8.0.16"
|
|
59
62
|
}
|
|
60
63
|
}
|
package/skill-invariants.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"comment": "Prose guardrails in SKILL.md that must survive edits
|
|
2
|
+
"comment": "Prose guardrails in SKILL.md that must survive edits (the `prose` array — each pattern is a case-insensitive regex tested against the full SKILL.md text) and code-level guards elsewhere in this skill's source (the `code` array — each pattern is tested against the file named by its own `file` field, not SKILL.md). Both are checked by tests/skill_contract.test.mjs. If you intentionally change one, update it here in the same commit and say why in the PR.",
|
|
3
3
|
"prose": [
|
|
4
4
|
{
|
|
5
5
|
"id": "immutable-entries",
|
|
@@ -60,7 +60,40 @@
|
|
|
60
60
|
"id": "no-push-retry",
|
|
61
61
|
"pattern": "do not retry automatically",
|
|
62
62
|
"rationale": "A failed push is surfaced to the user, not retried blind."
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"id": "private-project-no-links",
|
|
66
|
+
"pattern": "no post ever[\\s\\S]{0,10}links a commit for it",
|
|
67
|
+
"rationale": "A private project's commits must never scan as public just because remoteMatches + branch-contains happen to hold — losing this line reopens the dead-link-to-a-private-repo path."
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"id": "cover-composition-scope",
|
|
71
|
+
"pattern": "never the raw draft file, never[\\s\\S]{0,10}## Changelog",
|
|
72
|
+
"rationale": "Cover composition must draw only from title/tags/summary/## Shipped — never the raw draft or Changelog — losing this line reopens off-scope content leaking into an auto-composed image."
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"id": "cover-never-blocks-publish",
|
|
76
|
+
"pattern": "[Nn]ever block[\\s\\S]{0,10}publish on a missing style guide",
|
|
77
|
+
"rationale": "A missing/uninstalled style guide must degrade gracefully (no cover this run), never abort the whole publish."
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"id": "cover-review-gate",
|
|
81
|
+
"pattern": "this interactive review IS the quality gate for the cover",
|
|
82
|
+
"rationale": "The rendered cover must be shown to the user before push, the same way Step 4 gates the prose — losing this line reopens publishing an unreviewed cover."
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"id": "cover-custom-illustration",
|
|
86
|
+
"pattern": "cover that just re-renders the title in large text is a failure",
|
|
87
|
+
"rationale": "First shipped version of this feature produced a shared text-heavy template with a rotating stock shape — rejected as bland/repetitive. Losing this line reopens that regression. Independent of, not superseded by, the v0.9.0 geometry guard (cover-catalog-hero-overlap-guard, below): that guard is a mechanical check on an agent's rendered composition each time a cover is rendered; this line is a prose guardrail against a future SKILL.md/style-guide edit silently reintroducing the bland-template regression at the instruction level. Two different regression surfaces, both still worth guarding."
|
|
88
|
+
}
|
|
89
|
+
],
|
|
90
|
+
"code": [
|
|
91
|
+
{
|
|
92
|
+
"id": "cover-catalog-hero-overlap-guard",
|
|
93
|
+
"file": "lib/render_cover.mjs",
|
|
94
|
+
"pattern": "(?:getBoundingClientRect[\\s\\S]{0,400}hero-zone|hero-zone[\\s\\S]{0,400}getBoundingClientRect)",
|
|
95
|
+
"rationale": "The catalog-icon/hero-zone overlap check must stay wired into renderCoverImage() — losing it silently reopens the gap where a catalog icon (or two, connected by a line) can stand in for the required bespoke hero illustration."
|
|
63
96
|
}
|
|
64
97
|
],
|
|
65
|
-
"cli_commands_referenced": ["scan", "lint-post", "publish-entry", "add-project", "remove-project", "set", "config", "init"]
|
|
98
|
+
"cli_commands_referenced": ["scan", "lint-post", "publish-entry", "add-project", "remove-project", "set", "config", "init", "cover-context", "render-cover"]
|
|
66
99
|
}
|