@natjswenson/devlog 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +230 -120
- package/SKILL.md +74 -24
- package/bin/devlog.js +108 -6
- package/config.example.json +1 -0
- package/image-style/style-guide.example.md +29 -26
- package/lib/compose_art_cover.mjs +144 -0
- package/lib/config_ops.mjs +1 -0
- package/lib/core.mjs +3 -0
- package/lib/guide_draft.mjs +134 -0
- package/lib/guide_preview.css +3 -0
- package/lib/publish_guide.mjs +124 -0
- package/package.json +4 -3
- package/references/codex-cover-art.md +119 -0
- package/references/concept-guides.md +148 -0
- package/references/cover-spec.md +40 -0
- package/references/guide-publishing.md +245 -0
- package/skill-invariants.json +34 -13
package/SKILL.md
CHANGED
|
@@ -1,12 +1,29 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: devlog
|
|
3
|
-
description: Turn
|
|
3
|
+
description: Turn releases into researched how-to posts, or explicitly draft one complete concept guide readers can implement with their own coding agent. Supports Codex AI cover drafts and conversational devlog configuration.
|
|
4
4
|
user_invocable: true
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## Codex runtime
|
|
8
|
+
|
|
9
|
+
When running in Codex, invoke this skill as `$devlog`. Resolve scripts, assets,
|
|
10
|
+
and references from the directory containing this SKILL.md, regardless of the
|
|
11
|
+
current working directory. Existing `~/.claude/` personal-data paths remain valid
|
|
12
|
+
and are still used by the bundled scripts; they do not require Claude to run.
|
|
13
|
+
Map `Read`/`Write`/`Edit`/`Bash` to the available file and shell tools, and
|
|
14
|
+
`WebSearch`/`WebFetch` to available web tools. For `AskUserQuestion`, use an
|
|
15
|
+
available question tool or a concise chat question; wait for answers that gate
|
|
16
|
+
action. Use Codex's delegation tools for required subagents when available;
|
|
17
|
+
otherwise disclose that independent execution is unavailable. Discover connected
|
|
18
|
+
apps by capability rather than assuming Claude MCP tool names exist.
|
|
19
|
+
|
|
7
20
|
# /devlog — Release How-To Generator
|
|
8
21
|
|
|
9
|
-
|
|
22
|
+
<!-- press:runtime -->
|
|
23
|
+
In Claude Code, load `/press`; in Codex, load `$press`; then follow the shared PRESS terminal/UI contract from `brand/agent-ui.md`. Do not copy or override that contract here.
|
|
24
|
+
<!-- press:runtime -->
|
|
25
|
+
|
|
26
|
+
In the default Generate mode, you turn each **new version release** (a semver git tag) in the user's projects into a
|
|
10
27
|
published blog post, written in **the user's own voice**, and pushed to the GitHub repo
|
|
11
28
|
configured in `~/.claude/skills/devlog/config.json`.
|
|
12
29
|
|
|
@@ -21,6 +38,34 @@ Every agent-facing command prints JSON.
|
|
|
21
38
|
|
|
22
39
|
## Decide which mode you're in
|
|
23
40
|
|
|
41
|
+
Explicit draft requests take precedence over the default Generate routing:
|
|
42
|
+
|
|
43
|
+
- **Concept guide draft** — "draft one concept guide", "use the agent-friendly guide
|
|
44
|
+
method", or "consolidate these posts into one guide". Read
|
|
45
|
+
[references/concept-guides.md](references/concept-guides.md). Select at most one
|
|
46
|
+
transferable reader outcome and finish its implementation, handoff and independent
|
|
47
|
+
trial. This mode is draft-only; never fall through to Generate or publish-entry.
|
|
48
|
+
- **AI cover draft** — "create an AI cover draft for this article". Read
|
|
49
|
+
[references/codex-cover-art.md](references/codex-cover-art.md). Work from the supplied
|
|
50
|
+
article, without scanning releases or publishing. Codex native image generation is
|
|
51
|
+
capability-checked; Claude retains its existing local renderer. No implicit API fallback.
|
|
52
|
+
|
|
53
|
+
New helpers are `lint-guide`, `prepare-guide`, `compose-art-cover`, and `publish-guide`.
|
|
54
|
+
Only `publish-guide` writes prepared content to a clone; it requires matching local
|
|
55
|
+
execution, adaptation and review evidence. It does not push or deploy.
|
|
56
|
+
The draft helpers do not change persistent configuration or the content repository.
|
|
57
|
+
Use bundled `bin/devlog.js` relative to this loaded SKILL.md only when its runtime
|
|
58
|
+
dependencies resolve: check `node <absolute-skill-root>/bin/devlog.js --version` first.
|
|
59
|
+
A Git-installed plugin can contain the script without node_modules. If the script or
|
|
60
|
+
dependencies are missing, use `npx -y @natjswenson/devlog@0.14.0 <helper>`; do not install
|
|
61
|
+
dependencies into an internal plugin cache. A standalone SKILL.md installed by `init`
|
|
62
|
+
uses that same exact-version fallback. For a missing Chromium binary, install the
|
|
63
|
+
matching browser with `npx -y --package=@natjswenson/devlog@0.14.0 playwright install chromium`
|
|
64
|
+
and retry the render; do not silently change dependency versions. References are bundled beside
|
|
65
|
+
both host entrypoints and copied with standalone skill installation.
|
|
66
|
+
|
|
67
|
+
Otherwise use the existing modes:
|
|
68
|
+
|
|
24
69
|
1. **Configure** — the user wants to change what devlog tracks or how it behaves:
|
|
25
70
|
"add this repo to devlog", "stop tracking X", "set min sources to 4", "show my
|
|
26
71
|
devlog config". → **Configure mode**.
|
|
@@ -28,7 +73,15 @@ Every agent-facing command prints JSON.
|
|
|
28
73
|
"any new releases?". → **Status mode**.
|
|
29
74
|
3. **Generate** (default) — `/devlog` or `/devlog <project-key>`. → **Generate mode**.
|
|
30
75
|
|
|
31
|
-
|
|
76
|
+
For a normal Generate request, read validated config first. If the user explicitly
|
|
77
|
+
selected `generationMode: "concept"`, follow
|
|
78
|
+
[references/guide-publishing.md](references/guide-publishing.md): select at most one
|
|
79
|
+
complete guide, finish its draft/trial/review, then publish after all checks. Missing
|
|
80
|
+
`generationMode` or `"release"` retains the existing Generate workflow below. Explicit
|
|
81
|
+
draft requests above always remain draft-only, even with this preference. Set the
|
|
82
|
+
preference only when the user asks; an update must not silently opt existing users in.
|
|
83
|
+
|
|
84
|
+
In Generate mode, an entry corresponds to a **release**, not a day. Re-running Generate only produces
|
|
32
85
|
entries for tags that don't already have one — it is idempotent, and a published entry is
|
|
33
86
|
**immutable: never overwrite it** (`publish-entry` refuses; don't work around it).
|
|
34
87
|
|
|
@@ -42,6 +95,7 @@ Map the user's request onto the CLI — never hand-edit `config.json`:
|
|
|
42
95
|
| Add a project | `npx -y @natjswenson/devlog@latest add-project --yes --path <abs-path> [--key K] [--remote O/R] [--label L] [--tag-prefix P] [--path-filter F] [--private]` |
|
|
43
96
|
| Remove a project | `npx -y @natjswenson/devlog@latest remove-project <key> --yes` |
|
|
44
97
|
| Change a setting | `npx -y @natjswenson/devlog@latest set <field> <value>` (settable: `targetRepo`, `branch`, `targetDir`, `gitAuthor`, `githubUser`, `siteUrl`, `voicePath`, `deepDive.minSources`, `deepDive.topicDomains`) |
|
|
98
|
+
| Adopt one-guide generation for future normal runs | `npx -y @natjswenson/devlog@latest set generationMode concept` (use `release` to restore legacy generation) |
|
|
45
99
|
|
|
46
100
|
`targetDir` is the subdirectory of `targetRepo` that holds the devlog content tree
|
|
47
101
|
(e.g. `content/devlog` when the target is the site repo itself); unset/empty means the
|
|
@@ -342,9 +396,12 @@ The `--clone` flag always points at the CONTENT ROOT: `<abs-tmp>/<repo-name>` wh
|
|
|
342
396
|
`targetDir` is empty, `<abs-tmp>/<repo-name>/<targetDir>` when it's set. Git commands
|
|
343
397
|
always run against the clone root `<abs-tmp>/<repo-name>` regardless.
|
|
344
398
|
|
|
345
|
-
|
|
346
|
-
release's own `publish-entry` call
|
|
347
|
-
|
|
399
|
+
In this Generate path, each release also gets a cover image, created inline in this same loop right before that
|
|
400
|
+
release's own `publish-entry` call. In Codex, the dominant artwork MUST come from the native
|
|
401
|
+
image-generation tool as a persisted raster source; wireframes, hand-authored SVG, CSS drawings,
|
|
402
|
+
and title-only placeholders are not covers. The deterministic compositor may add PRESS typography
|
|
403
|
+
around that generated artwork, but it must never substitute for native image generation.
|
|
404
|
+
The cover must carry a custom illustration or other meaningful visual material; a cover that just re-renders the title in large text is a failure.
|
|
348
405
|
|
|
349
406
|
```bash
|
|
350
407
|
mktemp -d # → record the absolute path, e.g. /var/folders/.../tmp.abc
|
|
@@ -365,24 +422,17 @@ Per release:
|
|
|
365
422
|
returns the style guide, icon catalog, and up to 3 reference cover paths. **Read
|
|
366
423
|
only the single most recent reference image** (image reads are the expensive part;
|
|
367
424
|
open another only if you're genuinely unsure the new cover is distinct). On
|
|
368
|
-
`{"error": "style-guide-missing"}`:
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
(render-cover mechanically enforces this), one of two slots (single centered hero,
|
|
380
|
-
or two-node before/after), interior points snapped to a 25px grid; catalog icons
|
|
381
|
-
never go inside `#hero-zone` (optional small accent glyph near the kicker only,
|
|
382
|
-
bottom edge above y:400). Write the document with the Write tool to
|
|
383
|
-
`'<abs-scratch>/<key>/<version>.html'` — full `<!DOCTYPE html>` document, sized
|
|
384
|
-
`html, body { margin:0; width:1600px; height:900px; }`, font referenced only as
|
|
385
|
-
`font-family: 'DevlogCoverFont', sans-serif`.
|
|
425
|
+
`{"error": "style-guide-missing"}`: stop this release and report that publication is
|
|
426
|
+
blocked. A release entry is not publishable without a cover and agent handoff.
|
|
427
|
+
2. **Generate and compose.** Use ONLY this release's title/tags/summary/`## Shipped` text
|
|
428
|
+
(never the raw draft file, never `## Changelog`) plus the style guide and icon catalog to
|
|
429
|
+
brief the native image-generation tool. Request artwork only: one concrete mechanism,
|
|
430
|
+
no lettering, labels, numbers, logos, fake code, UI, wireframe, gradients, or watermark.
|
|
431
|
+
Persist and inspect the exact returned raster, then use `compose-art-cover` to add local
|
|
432
|
+
PRESS typography. Do not draw the dominant art as inline SVG or CSS. A cover that just
|
|
433
|
+
re-renders the title in large text is a failure. Match the established backfill look:
|
|
434
|
+
flat cream paper, monochrome technical engraving, black/gray ink, restrained crosshatching,
|
|
435
|
+
a quiet left field for the headline, and at most one small orange accent.
|
|
386
436
|
3. **Rasterize.**
|
|
387
437
|
`npx -y @natjswenson/devlog@latest render-cover '<abs-scratch>/<key>/<version>.html' --project '<key>' --slug '<version>' --out '<abs-scratch>'`
|
|
388
438
|
The HTML is the source of truth and **survives the render**: to fix a visual
|
package/bin/devlog.js
CHANGED
|
@@ -393,6 +393,16 @@ async function cmdInit() {
|
|
|
393
393
|
}
|
|
394
394
|
|
|
395
395
|
if (await confirmOverwrite('SKILL.md', SKILL_DEST)) {
|
|
396
|
+
// These are versioned instructions, not personal config/voice/style files.
|
|
397
|
+
// Install references with the entrypoint so standalone hosts can resolve them.
|
|
398
|
+
const references = join(PACKAGE_ROOT, 'references');
|
|
399
|
+
const referenceDest = join(CONFIG_DIR, 'references');
|
|
400
|
+
mkdirSync(referenceDest, { recursive: true, mode: 0o700 });
|
|
401
|
+
for (const name of readdirSync(references)) {
|
|
402
|
+
if (name.endsWith('.md') && statSync(join(references, name)).isFile()) {
|
|
403
|
+
copyFileSync(join(references, name), join(referenceDest, name));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
396
406
|
copyFileSync(SKILL_SRC, SKILL_DEST);
|
|
397
407
|
log.ok(`Installed SKILL.md → ${SKILL_DEST}`);
|
|
398
408
|
} else {
|
|
@@ -789,6 +799,79 @@ function cmdAssemblePost(rest) {
|
|
|
789
799
|
}
|
|
790
800
|
}
|
|
791
801
|
|
|
802
|
+
// Additive local draft helpers. No config reads, generation, or content writes.
|
|
803
|
+
async function cmdLintGuide(rest) {
|
|
804
|
+
const { values, positionals } = safeParseArgs({
|
|
805
|
+
args: rest, options: { voice: { type: 'boolean', default: false } }, allowPositionals: true,
|
|
806
|
+
});
|
|
807
|
+
if (positionals.length !== 1) emitJSON({ error: 'missing-arg', message: 'Usage: devlog lint-guide <article> [--voice]' }, 2);
|
|
808
|
+
try {
|
|
809
|
+
const { lintGuide } = await import('../lib/guide_draft.mjs');
|
|
810
|
+
const result = lintGuide(readFileSync(expandHome(positionals[0]), 'utf8'), { voice: values.voice });
|
|
811
|
+
emitJSON(result, result.ok ? 0 : 1);
|
|
812
|
+
} catch (e) {
|
|
813
|
+
emitJSON({ error: e.code || 'guide-lint-failed', message: e.message }, 1);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
async function cmdPrepareGuide(rest) {
|
|
818
|
+
const { values } = safeParseArgs({
|
|
819
|
+
args: rest,
|
|
820
|
+
options: { article: { type: 'string' }, brand: { type: 'string' }, out: { type: 'string' }, cover: { type: 'string' } },
|
|
821
|
+
allowPositionals: false,
|
|
822
|
+
});
|
|
823
|
+
for (const flag of ['article', 'brand', 'out']) {
|
|
824
|
+
if (!values[flag]) emitJSON({ error: 'missing-flag', message: `prepare-guide requires --${flag}` }, 2);
|
|
825
|
+
}
|
|
826
|
+
try {
|
|
827
|
+
const { prepareGuidePreview } = await import('../lib/guide_draft.mjs');
|
|
828
|
+
const result = await prepareGuidePreview({
|
|
829
|
+
articlePath: expandHome(values.article), brandPath: expandHome(values.brand),
|
|
830
|
+
outDir: expandHome(values.out), coverPath: values.cover ? expandHome(values.cover) : undefined,
|
|
831
|
+
});
|
|
832
|
+
emitJSON({ ok: true, ...result });
|
|
833
|
+
} catch (e) {
|
|
834
|
+
emitJSON({ error: e.code || 'guide-preview-failed', message: e.message, ...(e.findings ? { findings: e.findings } : {}) }, 1);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
async function cmdComposeArtCover(rest) {
|
|
839
|
+
const { values } = safeParseArgs({
|
|
840
|
+
args: rest, options: { spec: { type: 'string' }, out: { type: 'string' } }, allowPositionals: false,
|
|
841
|
+
});
|
|
842
|
+
for (const flag of ['spec', 'out']) {
|
|
843
|
+
if (!values[flag]) emitJSON({ error: 'missing-flag', message: `compose-art-cover requires --${flag}` }, 2);
|
|
844
|
+
}
|
|
845
|
+
try {
|
|
846
|
+
const { composeArtCover } = await import('../lib/compose_art_cover.mjs');
|
|
847
|
+
const result = await composeArtCover(expandHome(values.spec), expandHome(values.out));
|
|
848
|
+
emitJSON({ ok: true, ...result });
|
|
849
|
+
} catch (e) {
|
|
850
|
+
emitJSON({ error: e.code || 'art-compose-failed', message: e.message }, 1);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
async function cmdPublishGuide(rest) {
|
|
855
|
+
const { values } = safeParseArgs({
|
|
856
|
+
args: rest,
|
|
857
|
+
options: { clone: { type: 'string' }, article: { type: 'string' }, evidence: { type: 'string' }, cover: { type: 'string' } },
|
|
858
|
+
allowPositionals: false,
|
|
859
|
+
});
|
|
860
|
+
for (const flag of ['clone', 'article', 'evidence']) {
|
|
861
|
+
if (!values[flag]) emitJSON({ error: 'missing-flag', message: `publish-guide requires --${flag}` }, 2);
|
|
862
|
+
}
|
|
863
|
+
try {
|
|
864
|
+
const { publishGuide } = await import('../lib/publish_guide.mjs');
|
|
865
|
+
const result = await publishGuide({
|
|
866
|
+
cloneDir: expandHome(values.clone), articlePath: expandHome(values.article),
|
|
867
|
+
evidencePath: expandHome(values.evidence), coverPath: values.cover ? expandHome(values.cover) : undefined,
|
|
868
|
+
});
|
|
869
|
+
emitJSON(result);
|
|
870
|
+
} catch (e) {
|
|
871
|
+
emitJSON({ error: e.code || 'guide-publish-failed', message: e.message, ...(e.findings ? { findings: e.findings } : {}) }, 1);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
792
875
|
// ─── backfill-covers list ─────────────────────────────────────────────────────
|
|
793
876
|
function cmdBackfillCovers(rest) {
|
|
794
877
|
const sub = rest[0];
|
|
@@ -1043,15 +1126,18 @@ async function cmdCommitCovers(rest) {
|
|
|
1043
1126
|
// routinely runs well after the backfill/review session that produced the staging dir,
|
|
1044
1127
|
// and reusing an hours-or-days-old clone would risk mutating a manifest that's since
|
|
1045
1128
|
// moved on.
|
|
1046
|
-
const
|
|
1129
|
+
const repoDir = mkdtempSync(join(tmpdir(), 'devlog-commit-covers-'));
|
|
1047
1130
|
const branch = config.branch || 'main';
|
|
1048
1131
|
const cloneUrl = `https://github.com/${config.targetRepo}.git`;
|
|
1049
|
-
const cloneResult = spawnSync('git', ['clone', '--depth=1', '--branch', branch, cloneUrl,
|
|
1132
|
+
const cloneResult = spawnSync('git', ['clone', '--depth=1', '--branch', branch, cloneUrl, repoDir], { encoding: 'utf8' });
|
|
1050
1133
|
if (cloneResult.status !== 0) {
|
|
1051
|
-
rmSync(
|
|
1134
|
+
rmSync(repoDir, { recursive: true, force: true });
|
|
1052
1135
|
emitJSON({ error: 'clone-failed', message: cloneResult.stderr || 'git clone failed' }, 1);
|
|
1053
1136
|
return;
|
|
1054
1137
|
}
|
|
1138
|
+
// Manifests live under the configured content root; git operations still run
|
|
1139
|
+
// from the repository root.
|
|
1140
|
+
const cloneDir = join(repoDir, config.targetDir || '');
|
|
1055
1141
|
|
|
1056
1142
|
const summary = { written: [], skipped: [], failed: [], missingManifest: [] };
|
|
1057
1143
|
let bulkForceOverwriteCount = 0;
|
|
@@ -1116,20 +1202,20 @@ async function cmdCommitCovers(rest) {
|
|
|
1116
1202
|
['commit', '-m', `chore(devlog): add ${summary.written.length} cover image(s)`],
|
|
1117
1203
|
];
|
|
1118
1204
|
for (const args of steps) {
|
|
1119
|
-
const r = spawnSync('git', ['-C',
|
|
1205
|
+
const r = spawnSync('git', ['-C', repoDir, ...args], { encoding: 'utf8' });
|
|
1120
1206
|
if (r.status !== 0) {
|
|
1121
1207
|
emitJSON({ ok: false, ...summary, bulkForceOverwriteCount, error: 'git-commit-failed', message: r.stderr }, 1);
|
|
1122
1208
|
return;
|
|
1123
1209
|
}
|
|
1124
1210
|
}
|
|
1125
|
-
const push = spawnSync('git', ['-C',
|
|
1211
|
+
const push = spawnSync('git', ['-C', repoDir, 'push', '--no-tags', 'origin', branch], { encoding: 'utf8' });
|
|
1126
1212
|
if (push.status !== 0) {
|
|
1127
1213
|
emitJSON({ ok: false, ...summary, bulkForceOverwriteCount, error: 'git-push-failed', message: push.stderr }, 1);
|
|
1128
1214
|
return;
|
|
1129
1215
|
}
|
|
1130
1216
|
}
|
|
1131
1217
|
|
|
1132
|
-
rmSync(
|
|
1218
|
+
rmSync(repoDir, { recursive: true, force: true });
|
|
1133
1219
|
emitJSON({ ok: summary.failed.length === 0, ...summary, bulkForceOverwriteCount });
|
|
1134
1220
|
}
|
|
1135
1221
|
|
|
@@ -1257,6 +1343,10 @@ Used by the /devlog skill:
|
|
|
1257
1343
|
${kleur.cyan('npx @natjswenson/devlog scan [--project <key>] [--summary]')} JSON plan of new releases needing entries
|
|
1258
1344
|
${kleur.cyan('npx @natjswenson/devlog lint-post <file> [--voice]')} Deterministic post-contract check (+ voice rules)
|
|
1259
1345
|
${kleur.cyan('npx @natjswenson/devlog assemble-post <draft> --out <dir>')} Extract the draft's code blocks for the run-it check
|
|
1346
|
+
${kleur.cyan('devlog lint-guide <article> [--voice]')} Check a concept draft and its top-of-post handoff
|
|
1347
|
+
${kleur.cyan('devlog prepare-guide --article <md> --brand <json> --out <new-dir> [--cover <png>]')} Local reading preview with complete agent payload
|
|
1348
|
+
${kleur.cyan('devlog compose-art-cover --spec <json> --out <new-dir>')} Compose local raster art and typography; no AI call or publishing
|
|
1349
|
+
${kleur.cyan('devlog publish-guide --clone <content-root> --article <md> --evidence <json> [--cover <png>]')} Validate evidence and publish into clone; no push
|
|
1260
1350
|
${kleur.cyan('npx @natjswenson/devlog publish-entry ...')} Copy a drafted entry into the clone + update manifest (never overwrites)
|
|
1261
1351
|
${kleur.cyan('npx @natjswenson/devlog cover-context <project> <slug> --clone <dir>')} Style guide + reference-image paths for cover composition
|
|
1262
1352
|
${kleur.cyan('npx @natjswenson/devlog render-cover <html> --project <key> --slug <s> --out <dir>')} Rasterize a composed cover to PNG
|
|
@@ -1327,6 +1417,18 @@ if (isMain) {
|
|
|
1327
1417
|
case 'assemble-post':
|
|
1328
1418
|
cmdAssemblePost(rest);
|
|
1329
1419
|
break;
|
|
1420
|
+
case 'lint-guide':
|
|
1421
|
+
cmdLintGuide(rest);
|
|
1422
|
+
break;
|
|
1423
|
+
case 'prepare-guide':
|
|
1424
|
+
cmdPrepareGuide(rest);
|
|
1425
|
+
break;
|
|
1426
|
+
case 'publish-guide':
|
|
1427
|
+
await cmdPublishGuide(rest);
|
|
1428
|
+
break;
|
|
1429
|
+
case 'compose-art-cover':
|
|
1430
|
+
cmdComposeArtCover(rest);
|
|
1431
|
+
break;
|
|
1330
1432
|
case 'backfill-covers':
|
|
1331
1433
|
cmdBackfillCovers(rest);
|
|
1332
1434
|
break;
|
package/config.example.json
CHANGED
|
@@ -11,8 +11,9 @@ are how a from-scratch visual language stays consistent from post to post.
|
|
|
11
11
|
|
|
12
12
|
## What you're composing
|
|
13
13
|
|
|
14
|
-
A single
|
|
15
|
-
|
|
14
|
+
A single PRESS editorial cover that renders, at exactly 1600×900px, as a cover image for
|
|
15
|
+
one dev log entry. In Codex, the dominant image is a native generated raster illustration;
|
|
16
|
+
the local compositor adds the typography and layout. Compose from these
|
|
16
17
|
fields only — never open the post's raw markdown file, never reference any section other
|
|
17
18
|
than `## Shipped` (no `## Changelog`, no gotchas, no sources):
|
|
18
19
|
- `title`
|
|
@@ -34,11 +35,11 @@ Before writing any HTML, do this thinking step explicitly:
|
|
|
34
35
|
key that doesn't fit, a git tag being distrusted like any other user input, one broken
|
|
35
36
|
link in a chain, a filter separating signal from noise, a clock catching a stale
|
|
36
37
|
timestamp, two paths diverging and one being cut off.
|
|
37
|
-
3. Design ONE illustration
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
3. Design ONE native raster illustration that depicts that concept. Not a photo, stock
|
|
39
|
+
icon, screenshot, UI, wireframe, or geometric placeholder: a detailed original
|
|
40
|
+
**monochrome technical engraving in black and warm gray ink on flat cream paper**, in
|
|
41
|
+
the spirit of a historical engineering plate or newspaper field illustration, with at
|
|
42
|
+
most one small vermilion/orange focal accent. The art must look printed, not painterly.
|
|
42
43
|
4. That illustration is the dominant visual element of the cover — roughly half the
|
|
43
44
|
canvas, not a thumbnail in the corner. Title, kicker, and summary text support it;
|
|
44
45
|
they do not replace it.
|
|
@@ -151,10 +152,10 @@ For the two-node slot specifically: the accent icon's presence must not be read
|
|
|
151
152
|
`DevlogCoverFont` finishes loading. That's resolving a name the browser already has, not
|
|
152
153
|
embedding or fetching a file — no different in kind from the fallback this rule already
|
|
153
154
|
requires.
|
|
154
|
-
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
155
|
+
- The final cover must be self-contained and offline-renderable. Native raster artwork is
|
|
156
|
+
persisted locally and embedded by `compose-art-cover`; no remote images or web fonts.
|
|
157
|
+
Typography is rendered locally and the generated artwork is never replaced by inline
|
|
158
|
+
SVG, CSS shapes, a wireframe, or a title-only placeholder.
|
|
158
159
|
|
|
159
160
|
## Visual direction
|
|
160
161
|
|
|
@@ -165,20 +166,21 @@ site, not a marketing graphic and not a repeated template.
|
|
|
165
166
|
|
|
166
167
|
### Palette
|
|
167
168
|
|
|
168
|
-
|
|
169
|
-
- **
|
|
170
|
-
|
|
171
|
-
- **Dim** `#6E675C` — secondary text
|
|
172
|
-
- **
|
|
173
|
-
|
|
174
|
-
- **
|
|
175
|
-
- **
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
Prefer flat, limited color and solid/line fills over gradients or smooth shading
|
|
180
|
-
|
|
181
|
-
|
|
169
|
+
<!-- >>> press:palette v0.10.0 sha256:5904c52d4168 GENERATED by @natjswenson/press, do not edit -->
|
|
170
|
+
- **Paper** `#F5F0E6` — Warm cream. Flat — never gradiented, never textured.
|
|
171
|
+
- **Ink** `#181510` — Near-black. Text, headlines, and every structural rule.
|
|
172
|
+
- **Dim** `#6E675C` — Muted secondary text; the serif commentary voice's color.
|
|
173
|
+
- **Accent** `#E8501F` — THE one loud color. Spent once or twice per document, never as decoration.
|
|
174
|
+
- **Ink Mid** `#4A423A` — Mid ink step: a second series, a stacked-bar segment, a bar track.
|
|
175
|
+
- **Ink Faint** `#8A8272` — Decorative only — a faint element inside an illustration. Never body text, never a headline.
|
|
176
|
+
- **Terminal panel** (bg, text, dim, hot, prompt) `#141A26`, `#EFE9DC`, `#8A8478`, `#FF8A5C`, `#1E2738` — the one place the dark palette
|
|
177
|
+
survives, and only inside a terminal element. Never on paper.
|
|
178
|
+
<!-- <<< press:palette -->
|
|
179
|
+
|
|
180
|
+
Prefer flat, limited color and solid/line fills over gradients or smooth shading. Native
|
|
181
|
+
art should be predominantly cream paper plus black/gray engraving lines, with no broad
|
|
182
|
+
color wash, no saturated palette, and no painterly digital lighting. The only bright color
|
|
183
|
+
is the single small orange accent permitted by the accent law below.
|
|
182
184
|
|
|
183
185
|
### The accent law, carried to covers
|
|
184
186
|
|
|
@@ -304,7 +306,8 @@ summary as on-image text.
|
|
|
304
306
|
cursor, no bare `_` suffix, no stray `$` prompt as decoration. The dark palette now
|
|
305
307
|
belongs to exactly one place, the `.term` panel, and only when it's real code.
|
|
306
308
|
- Don't embed a photograph, stock image, or anything requiring an external fetch — the
|
|
307
|
-
illustration is
|
|
309
|
+
illustration is native-generated raster artwork persisted locally and embedded by the
|
|
310
|
+
compositor, never a hand-authored SVG substitute.
|
|
308
311
|
- Don't reference any font file other than the bundled `'DevlogCoverFont'` — the serif
|
|
309
312
|
and display voices lean on this rendering host's own system fonts, never a file you
|
|
310
313
|
fetch or embed yourself.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Additive, offline raster-art compositor. Success records rendering, never visual approval.
|
|
2
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import sharp from 'sharp';
|
|
6
|
+
import { chromium } from 'playwright';
|
|
7
|
+
|
|
8
|
+
const hash = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
9
|
+
const escape = value => value.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
10
|
+
const fail = (code, message) => Object.assign(new Error(message), { code });
|
|
11
|
+
function string(value, field, max, optional = false) {
|
|
12
|
+
if (optional && value === undefined) return '';
|
|
13
|
+
if (typeof value !== 'string' || !value.trim() || value.length > max || /[\u0000-\u001f]/.test(value)) {
|
|
14
|
+
throw fail('ART_SPEC_INVALID', `${field} must be nonempty text of at most ${max} characters without control characters`);
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
function local(value, field, base) {
|
|
19
|
+
string(value, field, 4096);
|
|
20
|
+
if (!path.isAbsolute(value) && /^[a-z][a-z0-9+.-]*:/i.test(value)) throw fail('ART_SPEC_INVALID', `${field} must be a local filesystem path`);
|
|
21
|
+
return path.resolve(base, value);
|
|
22
|
+
}
|
|
23
|
+
async function json(file, field) {
|
|
24
|
+
const bytes = await readFile(file);
|
|
25
|
+
try { return { bytes, value: JSON.parse(bytes.toString('utf8')) }; }
|
|
26
|
+
catch { throw fail('ART_SPEC_INVALID', `${field} must contain valid JSON`); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Schema 1: {schema:1, source, brand, title, kicker?, stand?, fontPath?}.
|
|
30
|
+
* Paths in the spec resolve against its directory; outDir resolves against cwd.
|
|
31
|
+
* Output directory must not exist. On failure it may contain partial artifacts,
|
|
32
|
+
* but never result.json; the compositor never deletes any directory.
|
|
33
|
+
*/
|
|
34
|
+
export async function composeArtCover(specPath, outDir) {
|
|
35
|
+
try { return await compose(specPath, outDir); }
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error.code?.startsWith('ART_')) throw error;
|
|
38
|
+
throw fail('ART_COMPOSE_FAILED', `Art cover failed: ${error.message}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function compose(specPath, outDir) {
|
|
42
|
+
const resolvedSpec = path.resolve(string(specPath, 'specPath', 4096));
|
|
43
|
+
const destination = path.resolve(string(outDir, 'outDir', 4096));
|
|
44
|
+
const base = path.dirname(resolvedSpec);
|
|
45
|
+
const { bytes: specBytes, value: spec } = await json(resolvedSpec, 'spec');
|
|
46
|
+
if (!spec || spec.schema !== 1 || Array.isArray(spec)) throw fail('ART_SPEC_INVALID', 'spec.schema must be 1');
|
|
47
|
+
const known = new Set(['schema', 'source', 'brand', 'title', 'kicker', 'stand', 'fontPath']);
|
|
48
|
+
if (Object.keys(spec).some(key => !known.has(key))) throw fail('ART_SPEC_INVALID', 'Unknown spec field; HTML/CSS input is not supported');
|
|
49
|
+
const title = string(spec.title, 'title', 300);
|
|
50
|
+
const kicker = string(spec.kicker, 'kicker', 80, true) || 'ENGINEERING FIELD NOTES';
|
|
51
|
+
const stand = string(spec.stand, 'stand', 180, true);
|
|
52
|
+
const sourcePath = local(spec.source, 'source', base);
|
|
53
|
+
const brandPath = local(spec.brand, 'brand', base);
|
|
54
|
+
const { bytes: brandBytes, value: brand } = await json(brandPath, 'brand');
|
|
55
|
+
for (const key of ['paper', 'ink', 'dim', 'accent']) {
|
|
56
|
+
if (!/^#[\da-f]{6}$/i.test(brand?.colors?.[key] ?? '')) throw fail('ART_BRAND_INVALID', `brand.colors.${key} must be a six-digit hex color`);
|
|
57
|
+
}
|
|
58
|
+
for (const key of ['display_stack', 'serif_stack', 'mono_stack']) {
|
|
59
|
+
const value = brand?.fonts?.[key];
|
|
60
|
+
if (typeof value !== 'string' || !/^[a-z\d ,"'_-]{1,500}$/i.test(value)) throw fail('ART_BRAND_INVALID', `brand.fonts.${key} must be a safe local font stack`);
|
|
61
|
+
}
|
|
62
|
+
const stamp = string(brand?.identity?.stamp, 'brand.identity.stamp', 12);
|
|
63
|
+
const name = string(brand?.identity?.name, 'brand.identity.name', 80);
|
|
64
|
+
const sourceBytes = await readFile(sourcePath);
|
|
65
|
+
let metadata, raster;
|
|
66
|
+
try {
|
|
67
|
+
metadata = await sharp(sourceBytes, { animated: true, limitInputPixels: 40_000_000 }).metadata();
|
|
68
|
+
if (!['png', 'jpeg', 'webp'].includes(metadata.format) || (metadata.pages ?? 1) !== 1) throw Error('Only single-frame PNG, JPEG, or WebP artwork is supported');
|
|
69
|
+
raster = await sharp(sourceBytes, { failOn: 'warning', limitInputPixels: 40_000_000 }).rotate().toColourspace('srgb').png({ palette: false }).toBuffer();
|
|
70
|
+
} catch (e) { throw fail('ART_SOURCE_INVALID', `Cannot decode source artwork: ${e.message}`); }
|
|
71
|
+
let fontCss = '', fontInfo = { mode: 'system-stacks', hostDependent: true, stacks: brand.fonts };
|
|
72
|
+
let display = brand.fonts.display_stack;
|
|
73
|
+
if (spec.fontPath !== undefined) {
|
|
74
|
+
const fontPath = local(spec.fontPath, 'fontPath', base);
|
|
75
|
+
const fontBytes = await readFile(fontPath);
|
|
76
|
+
if (!fontBytes.length || fontBytes.length > 10_000_000) throw fail('ART_FONT_INVALID', 'Local font must contain 1 to 10000000 bytes');
|
|
77
|
+
fontCss = `@font-face{font-family:ArtCoverDisplay;src:url(data:font/ttf;base64,${fontBytes.toString('base64')})}`;
|
|
78
|
+
display = `'ArtCoverDisplay', ${display}`;
|
|
79
|
+
fontInfo = { mode: 'embedded-display', path: fontPath, sha256: hash(fontBytes), hostDependent: true, fallback: 'Display glyph fallback and serif/mono stacks remain host dependent', stacks: brand.fonts };
|
|
80
|
+
}
|
|
81
|
+
const c = brand.colors;
|
|
82
|
+
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; font-src data:; style-src 'unsafe-inline'"><title>${escape(title)}</title><style>
|
|
83
|
+
${fontCss}
|
|
84
|
+
*{box-sizing:border-box}html,body{margin:0;width:1600px;height:900px;background:${c.paper};color:${c.ink}}
|
|
85
|
+
main{position:relative;width:1600px;height:900px;padding:54px 64px;overflow:hidden}
|
|
86
|
+
header{border-top:8px solid ${c.ink};display:flex;align-items:center;gap:20px;padding-top:18px}
|
|
87
|
+
.stamp{font:900 25px ${display};border:3px solid ${c.accent};padding:8px;transform:rotate(-4deg)}
|
|
88
|
+
.kicker,.name,footer{font:16px ${brand.fonts.mono_stack}}.name{margin-left:auto;color:${c.dim}}.kicker{max-width:820px}
|
|
89
|
+
h1{position:absolute;left:64px;top:203px;width:495px;margin:0;font:900 70px/1.02 ${display};letter-spacing:-.03em;overflow-wrap:anywhere}
|
|
90
|
+
.art{position:absolute;left:580px;top:196px;width:970px;height:610px;object-fit:contain}
|
|
91
|
+
.stand{position:absolute;left:66px;top:675px;width:465px;max-height:128px;margin:0;font:italic 26px/1.3 ${brand.fonts.serif_stack};color:${c.dim}}
|
|
92
|
+
footer{position:absolute;left:64px;right:64px;bottom:35px;border-top:2px solid ${c.ink};padding-top:16px;color:${c.dim}}
|
|
93
|
+
</style></head><body><main><header><div class="stamp">${escape(stamp)}</div><div class="kicker">${escape(kicker)}</div><div class="name">${escape(name)}</div></header><h1>${escape(title)}</h1><img class="art" alt="" src="data:image/png;base64,${raster.toString('base64')}"><p class="stand">${escape(stand)}</p><footer>IMPLEMENT IT IN YOUR PROJECT</footer></main></body></html>`;
|
|
94
|
+
// Exclusive mkdir establishes ownership; no recursive mkdir and no deletion on errors.
|
|
95
|
+
try { await mkdir(destination); }
|
|
96
|
+
catch (e) { throw fail(e.code === 'EEXIST' ? 'ART_OUTPUT_EXISTS' : 'ART_OUTPUT_INVALID', `Output must be a new directory with an existing parent: ${destination} (${e.message})`); }
|
|
97
|
+
let browser, imageBytes, geometry;
|
|
98
|
+
try {
|
|
99
|
+
browser = await chromium.launch({ headless: true, timeout: 15000 });
|
|
100
|
+
const page = await browser.newPage({ viewport: { width: 1600, height: 900 }, deviceScaleFactor: 1, serviceWorkers: 'block' });
|
|
101
|
+
await page.route('**/*', route => route.abort());
|
|
102
|
+
await page.setContent(html, { waitUntil: 'load', timeout: 15000 });
|
|
103
|
+
geometry = await page.evaluate(async hasFont => {
|
|
104
|
+
await Promise.race([
|
|
105
|
+
(async () => {
|
|
106
|
+
if (hasFont) {
|
|
107
|
+
try { if (!(await document.fonts.load('70px ArtCoverDisplay')).length) throw Error('No matching face'); }
|
|
108
|
+
catch { throw Error('Embedded display font failed to load'); }
|
|
109
|
+
}
|
|
110
|
+
await document.fonts.ready;
|
|
111
|
+
await Promise.all([...document.images].map(image => image.decode()));
|
|
112
|
+
})(),
|
|
113
|
+
new Promise((_, reject) => setTimeout(() => reject(Error('Font/image readiness timed out')), 10000)),
|
|
114
|
+
]);
|
|
115
|
+
if (document.querySelector('header').getBoundingClientRect().bottom > 176) throw Error('Header overflows into the title/art area; shorten the kicker or identity');
|
|
116
|
+
const heading = document.querySelector('h1');
|
|
117
|
+
let size = 70;
|
|
118
|
+
while (heading.getBoundingClientRect().bottom > 645 && size > 54) { size -= 2; heading.style.fontSize = `${size}px`; }
|
|
119
|
+
if (heading.getBoundingClientRect().bottom > 645) throw Error('Title overflows at minimum 54px; shorten it');
|
|
120
|
+
const bounds = {};
|
|
121
|
+
for (const selector of ['h1', '.art', '.stand', '.stamp', '.kicker', '.name', 'footer']) {
|
|
122
|
+
const el = document.querySelector(selector), r = el.getBoundingClientRect();
|
|
123
|
+
if (r.left < 0 || r.top < 0 || r.right > 1600 || r.bottom > 900 || el.scrollWidth > el.clientWidth + 1 || (selector === '.stand' && el.scrollHeight > el.clientHeight + 1)) throw Error(`Text or art overflows: ${selector}`);
|
|
124
|
+
bounds[selector] = { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
125
|
+
}
|
|
126
|
+
return { titleFontSize: size, bounds };
|
|
127
|
+
}, Boolean(spec.fontPath));
|
|
128
|
+
imageBytes = await page.screenshot({ type: 'png', timeout: 15000 });
|
|
129
|
+
} catch (e) { throw fail('ART_RENDER_FAILED', `Offline cover render failed: ${e.message}`); }
|
|
130
|
+
finally { if (browser) await browser.close(); }
|
|
131
|
+
const cover = await sharp(imageBytes).removeAlpha().toColourspace('srgb').png({ palette: false, compressionLevel: 9 }).toBuffer();
|
|
132
|
+
const thumbnail = await sharp(cover).resize(320, 180).png({ palette: false }).toBuffer();
|
|
133
|
+
const artifacts = { 'composition.html': Buffer.from(html), 'cover.png': cover, 'thumbnail.png': thumbnail };
|
|
134
|
+
for (const [file, bytes] of Object.entries(artifacts)) await writeFile(path.join(destination, file), bytes, { flag: 'wx' });
|
|
135
|
+
const result = {
|
|
136
|
+
schema: 1, renderer: 'offline-raster-art-v1', visualReview: 'pending', outputDir: destination,
|
|
137
|
+
inputs: { spec: { path: resolvedSpec, sha256: hash(specBytes) }, source: { path: sourcePath, sha256: hash(sourceBytes), format: metadata.format, width: metadata.width, height: metadata.height }, brand: { path: brandPath, sha256: hash(brandBytes) }, font: fontInfo },
|
|
138
|
+
title, geometry, width: 1600, height: 900, palette: false,
|
|
139
|
+
outputs: Object.fromEntries(Object.entries(artifacts).map(([file, bytes]) => [file, { sha256: hash(bytes), bytes: bytes.length }])),
|
|
140
|
+
};
|
|
141
|
+
// Commit marker written last. Absence means this attempt did not complete.
|
|
142
|
+
await writeFile(path.join(destination, 'result.json'), JSON.stringify(result, null, 2) + '\n', { flag: 'wx' });
|
|
143
|
+
return result;
|
|
144
|
+
}
|
package/lib/config_ops.mjs
CHANGED
|
@@ -29,6 +29,7 @@ export function removeProject(config, key) {
|
|
|
29
29
|
// Fields settable via `devlog set <field> <value>`. Everything funnels through
|
|
30
30
|
// validateConfig, so a bad value can never be persisted.
|
|
31
31
|
const SETTERS = {
|
|
32
|
+
generationMode: (c, v) => ({ ...c, generationMode: v }),
|
|
32
33
|
targetRepo: (c, v) => ({ ...c, targetRepo: v }),
|
|
33
34
|
branch: (c, v) => ({ ...c, branch: v }),
|
|
34
35
|
targetDir: (c, v) => (v === '' ? omit(c, 'targetDir') : { ...c, targetDir: v }),
|
package/lib/core.mjs
CHANGED
|
@@ -150,6 +150,9 @@ export function validateConfig(config) {
|
|
|
150
150
|
throw new Error(`voicePath must be a path with no shell metacharacters and no leading dash: got ${JSON.stringify(config.voicePath)}`);
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
|
+
if ('generationMode' in config && !['release', 'concept'].includes(config.generationMode)) {
|
|
154
|
+
throw new Error('generationMode must be release or concept');
|
|
155
|
+
}
|
|
153
156
|
if ('deepDive' in config) {
|
|
154
157
|
const d = config.deepDive;
|
|
155
158
|
if (!d || typeof d !== 'object' || Array.isArray(d)) throw new Error('deepDive must be an object');
|