@natjswenson/devlog 0.12.0 → 0.14.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/CHANGELOG.md +55 -0
- package/README.md +230 -120
- package/SKILL.md +126 -11
- package/bin/devlog.js +99 -0
- package/config.example.json +1 -0
- package/image-style/style-guide.example.md +10 -10
- package/lib/compose_art_cover.mjs +144 -0
- package/lib/config_ops.mjs +2 -0
- package/lib/core.mjs +16 -0
- package/lib/guide_draft.mjs +134 -0
- package/lib/guide_preview.css +3 -0
- package/lib/publish_entry.mjs +15 -1
- package/lib/publish_guide.mjs +124 -0
- package/lib/scan.mjs +5 -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 +31 -10
package/SKILL.md
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
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
|
+
In the default Generate mode, you turn each **new version release** (a semver git tag) in the user's projects into a
|
|
10
23
|
published blog post, written in **the user's own voice**, and pushed to the GitHub repo
|
|
11
24
|
configured in `~/.claude/skills/devlog/config.json`.
|
|
12
25
|
|
|
@@ -21,6 +34,34 @@ Every agent-facing command prints JSON.
|
|
|
21
34
|
|
|
22
35
|
## Decide which mode you're in
|
|
23
36
|
|
|
37
|
+
Explicit draft requests take precedence over the default Generate routing:
|
|
38
|
+
|
|
39
|
+
- **Concept guide draft** — "draft one concept guide", "use the agent-friendly guide
|
|
40
|
+
method", or "consolidate these posts into one guide". Read
|
|
41
|
+
[references/concept-guides.md](references/concept-guides.md). Select at most one
|
|
42
|
+
transferable reader outcome and finish its implementation, handoff and independent
|
|
43
|
+
trial. This mode is draft-only; never fall through to Generate or publish-entry.
|
|
44
|
+
- **AI cover draft** — "create an AI cover draft for this article". Read
|
|
45
|
+
[references/codex-cover-art.md](references/codex-cover-art.md). Work from the supplied
|
|
46
|
+
article, without scanning releases or publishing. Codex native image generation is
|
|
47
|
+
capability-checked; Claude retains its existing local renderer. No implicit API fallback.
|
|
48
|
+
|
|
49
|
+
New helpers are `lint-guide`, `prepare-guide`, `compose-art-cover`, and `publish-guide`.
|
|
50
|
+
Only `publish-guide` writes prepared content to a clone; it requires matching local
|
|
51
|
+
execution, adaptation and review evidence. It does not push or deploy.
|
|
52
|
+
The draft helpers do not change persistent configuration or the content repository.
|
|
53
|
+
Use bundled `bin/devlog.js` relative to this loaded SKILL.md only when its runtime
|
|
54
|
+
dependencies resolve: check `node <absolute-skill-root>/bin/devlog.js --version` first.
|
|
55
|
+
A Git-installed plugin can contain the script without node_modules. If the script or
|
|
56
|
+
dependencies are missing, use `npx -y @natjswenson/devlog@0.14.0 <helper>`; do not install
|
|
57
|
+
dependencies into an internal plugin cache. A standalone SKILL.md installed by `init`
|
|
58
|
+
uses that same exact-version fallback. For a missing Chromium binary, install the
|
|
59
|
+
matching browser with `npx -y --package=@natjswenson/devlog@0.14.0 playwright install chromium`
|
|
60
|
+
and retry the render; do not silently change dependency versions. References are bundled beside
|
|
61
|
+
both host entrypoints and copied with standalone skill installation.
|
|
62
|
+
|
|
63
|
+
Otherwise use the existing modes:
|
|
64
|
+
|
|
24
65
|
1. **Configure** — the user wants to change what devlog tracks or how it behaves:
|
|
25
66
|
"add this repo to devlog", "stop tracking X", "set min sources to 4", "show my
|
|
26
67
|
devlog config". → **Configure mode**.
|
|
@@ -28,7 +69,15 @@ Every agent-facing command prints JSON.
|
|
|
28
69
|
"any new releases?". → **Status mode**.
|
|
29
70
|
3. **Generate** (default) — `/devlog` or `/devlog <project-key>`. → **Generate mode**.
|
|
30
71
|
|
|
31
|
-
|
|
72
|
+
For a normal Generate request, read validated config first. If the user explicitly
|
|
73
|
+
selected `generationMode: "concept"`, follow
|
|
74
|
+
[references/guide-publishing.md](references/guide-publishing.md): select at most one
|
|
75
|
+
complete guide, finish its draft/trial/review, then publish after all checks. Missing
|
|
76
|
+
`generationMode` or `"release"` retains the existing Generate workflow below. Explicit
|
|
77
|
+
draft requests above always remain draft-only, even with this preference. Set the
|
|
78
|
+
preference only when the user asks; an update must not silently opt existing users in.
|
|
79
|
+
|
|
80
|
+
In Generate mode, an entry corresponds to a **release**, not a day. Re-running Generate only produces
|
|
32
81
|
entries for tags that don't already have one — it is idempotent, and a published entry is
|
|
33
82
|
**immutable: never overwrite it** (`publish-entry` refuses; don't work around it).
|
|
34
83
|
|
|
@@ -41,12 +90,20 @@ Map the user's request onto the CLI — never hand-edit `config.json`:
|
|
|
41
90
|
| Show config | `npx -y @natjswenson/devlog@latest config --json` |
|
|
42
91
|
| 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
92
|
| Remove a project | `npx -y @natjswenson/devlog@latest remove-project <key> --yes` |
|
|
44
|
-
| Change a setting | `npx -y @natjswenson/devlog@latest set <field> <value>` (settable: `targetRepo`, `branch`, `targetDir`, `gitAuthor`, `githubUser`, `voicePath`, `deepDive.minSources`, `deepDive.topicDomains`) |
|
|
93
|
+
| Change a setting | `npx -y @natjswenson/devlog@latest set <field> <value>` (settable: `targetRepo`, `branch`, `targetDir`, `gitAuthor`, `githubUser`, `siteUrl`, `voicePath`, `deepDive.minSources`, `deepDive.topicDomains`) |
|
|
94
|
+
| Adopt one-guide generation for future normal runs | `npx -y @natjswenson/devlog@latest set generationMode concept` (use `release` to restore legacy generation) |
|
|
45
95
|
|
|
46
96
|
`targetDir` is the subdirectory of `targetRepo` that holds the devlog content tree
|
|
47
97
|
(e.g. `content/devlog` when the target is the site repo itself); unset/empty means the
|
|
48
98
|
repo root. Set it with `set targetDir content/devlog`, clear it with `set targetDir ''`.
|
|
49
99
|
|
|
100
|
+
`siteUrl` is the public base URL that repo is *served* at, with no trailing slash — used
|
|
101
|
+
to verify a published entry actually renders (Generate step 6). **The repo name is not the
|
|
102
|
+
site name**: a repo called `example.io` is commonly served at `example.com`, so take this
|
|
103
|
+
from a working page, not from the repo. Without it a run can push but cannot confirm
|
|
104
|
+
anything went live. Set with `set siteUrl 'https://example.com'`, clear with
|
|
105
|
+
`set siteUrl ''`.
|
|
106
|
+
|
|
50
107
|
For **add-project**: resolve the path first (the repo the user named, or the cwd), then
|
|
51
108
|
detect what the CLI will use — key = directory basename, remote = `git -C '<path>' remote
|
|
52
109
|
get-url origin`. In a monorepo, suggest a `--path-filter` (the project's subdir) and a
|
|
@@ -335,7 +392,7 @@ The `--clone` flag always points at the CONTENT ROOT: `<abs-tmp>/<repo-name>` wh
|
|
|
335
392
|
`targetDir` is empty, `<abs-tmp>/<repo-name>/<targetDir>` when it's set. Git commands
|
|
336
393
|
always run against the clone root `<abs-tmp>/<repo-name>` regardless.
|
|
337
394
|
|
|
338
|
-
|
|
395
|
+
In this legacy Generate path, each release also gets a cover image, composed inline in this same loop right before that
|
|
339
396
|
release's own `publish-entry` call — a self-contained HTML/CSS (or inline SVG) document,
|
|
340
397
|
rasterized locally, never sent to any external service:
|
|
341
398
|
|
|
@@ -412,7 +469,65 @@ rm -rf '<abs-tmp>'
|
|
|
412
469
|
|
|
413
470
|
If the push fails, report the error and stop — do not retry automatically.
|
|
414
471
|
|
|
415
|
-
### Step
|
|
472
|
+
### Step 5b: Register a project the site has never rendered before
|
|
473
|
+
|
|
474
|
+
`publish-entry` returns **`firstEntryForProject: true`** when the call created that
|
|
475
|
+
project's first live entry. **A push is not a route.** A site that renders the devlog
|
|
476
|
+
almost always keeps its own registry deciding *which* content directories become pages;
|
|
477
|
+
content on disk that the registry doesn't list is built right past, and the entry 404s
|
|
478
|
+
while every command in this skill reports success. Publishing a project's first entry is
|
|
479
|
+
therefore a two-part job, and the CLI can only do the first part.
|
|
480
|
+
|
|
481
|
+
When `firstEntryForProject` is true, **before the push**:
|
|
482
|
+
|
|
483
|
+
1. Find the registry in the clone. It is a source file, not content — grep the site's
|
|
484
|
+
`src/` for the existing project keys (`grep -rn '<a-known-project-key>' <clone>/src`).
|
|
485
|
+
In an Astro/Next-style site it is typically a `PROJECTS` array the entry loader maps
|
|
486
|
+
over.
|
|
487
|
+
2. Add the new project, matching the shape of the neighbouring rows exactly.
|
|
488
|
+
3. **Build the site in the clone and confirm the route exists** (`npm ci && npx astro
|
|
489
|
+
build`, then check the output directory contains the new `<project>/<version>` path).
|
|
490
|
+
A registry edit that doesn't produce a route is not done.
|
|
491
|
+
4. Run the site's own test suite if it has one, and commit the registry change together
|
|
492
|
+
with the content in the same push.
|
|
493
|
+
|
|
494
|
+
If you cannot find a registry, say so plainly rather than assuming there isn't one; the
|
|
495
|
+
verification in Step 6 is what actually settles it.
|
|
496
|
+
|
|
497
|
+
**Registry position can decide feed order, not just visibility.** If the site sorts
|
|
498
|
+
entries by date alone, same-date entries fall back to their load order, which is registry
|
|
499
|
+
order — so a project appended to the end can render its newest entry last. After the build
|
|
500
|
+
in step 3, check that the new entry appears where its date and publish numeral say it
|
|
501
|
+
should, and report it if not.
|
|
502
|
+
|
|
503
|
+
### Step 6: Verify the entry is actually live
|
|
504
|
+
|
|
505
|
+
**Do not report a publish as successful until the published URL returns 200.** The push
|
|
506
|
+
succeeding, the manifest updating, and the site serving the post are three different
|
|
507
|
+
things, and this skill has previously reported success on all of the first two while the
|
|
508
|
+
post 404'd.
|
|
509
|
+
|
|
510
|
+
When `siteUrl` is set in config (it is echoed in the scan output):
|
|
511
|
+
|
|
512
|
+
```bash
|
|
513
|
+
curl -s -o /dev/null -w '%{http_code}' -L '<siteUrl>/devlog/<project>/<version>/'
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
Poll every ~45s for up to ~5 minutes; deploys are not instant. Then:
|
|
517
|
+
|
|
518
|
+
- **200** → done, report the live URL as the primary link.
|
|
519
|
+
- **Still 404 after the window** → the deploy failed or the entry is not routed. Check
|
|
520
|
+
`firstEntryForProject`/the registry from Step 5b first, since that is the most common
|
|
521
|
+
cause, then the host's build log. Report it as **not live**, with what you checked.
|
|
522
|
+
- **`siteUrl` unset** → say the entry is pushed but unverified, give the repo URL, and
|
|
523
|
+
offer to set it: `npx -y @natjswenson/devlog@latest set siteUrl 'https://example.com'`.
|
|
524
|
+
Never describe an unverified push as published.
|
|
525
|
+
|
|
526
|
+
The `/devlog/<project>/<version>/` path is this skill's default convention. If the site
|
|
527
|
+
routes differently, take the pattern from a URL of an already-published entry rather than
|
|
528
|
+
assuming this one.
|
|
529
|
+
|
|
530
|
+
### Step 7: Confirm
|
|
416
531
|
|
|
417
532
|
```
|
|
418
533
|
Release dev log entries published
|
|
@@ -420,13 +535,13 @@ Release dev log entries published
|
|
|
420
535
|
Project: <key>
|
|
421
536
|
Releases: <version>, ...
|
|
422
537
|
Judged weaknesses: <residuals from Step 4, or "none">
|
|
423
|
-
|
|
538
|
+
Live: <siteUrl>/devlog/<key>/<version>/ (verified 200)
|
|
539
|
+
Source: https://github.com/<targetRepo>/blob/<branch>/<targetDir-prefix><key>/<version>.md
|
|
424
540
|
```
|
|
425
541
|
|
|
426
|
-
(`<targetDir-prefix>` is `<targetDir>/` when set, empty otherwise.)
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
next deploy.
|
|
542
|
+
(`<targetDir-prefix>` is `<targetDir>/` when set, empty otherwise.) Report the **verified
|
|
543
|
+
live URL** as the primary link; the repo blob URL is the secondary one. If Step 6 could
|
|
544
|
+
not verify, say so on the `Live:` line instead of printing a URL that may 404.
|
|
430
545
|
|
|
431
546
|
## Security rules
|
|
432
547
|
|
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];
|
|
@@ -1257,6 +1340,10 @@ Used by the /devlog skill:
|
|
|
1257
1340
|
${kleur.cyan('npx @natjswenson/devlog scan [--project <key>] [--summary]')} JSON plan of new releases needing entries
|
|
1258
1341
|
${kleur.cyan('npx @natjswenson/devlog lint-post <file> [--voice]')} Deterministic post-contract check (+ voice rules)
|
|
1259
1342
|
${kleur.cyan('npx @natjswenson/devlog assemble-post <draft> --out <dir>')} Extract the draft's code blocks for the run-it check
|
|
1343
|
+
${kleur.cyan('devlog lint-guide <article> [--voice]')} Check a concept draft and its top-of-post handoff
|
|
1344
|
+
${kleur.cyan('devlog prepare-guide --article <md> --brand <json> --out <new-dir> [--cover <png>]')} Local reading preview with complete agent payload
|
|
1345
|
+
${kleur.cyan('devlog compose-art-cover --spec <json> --out <new-dir>')} Compose local raster art and typography; no AI call or publishing
|
|
1346
|
+
${kleur.cyan('devlog publish-guide --clone <content-root> --article <md> --evidence <json> [--cover <png>]')} Validate evidence and publish into clone; no push
|
|
1260
1347
|
${kleur.cyan('npx @natjswenson/devlog publish-entry ...')} Copy a drafted entry into the clone + update manifest (never overwrites)
|
|
1261
1348
|
${kleur.cyan('npx @natjswenson/devlog cover-context <project> <slug> --clone <dir>')} Style guide + reference-image paths for cover composition
|
|
1262
1349
|
${kleur.cyan('npx @natjswenson/devlog render-cover <html> --project <key> --slug <s> --out <dir>')} Rasterize a composed cover to PNG
|
|
@@ -1327,6 +1414,18 @@ if (isMain) {
|
|
|
1327
1414
|
case 'assemble-post':
|
|
1328
1415
|
cmdAssemblePost(rest);
|
|
1329
1416
|
break;
|
|
1417
|
+
case 'lint-guide':
|
|
1418
|
+
cmdLintGuide(rest);
|
|
1419
|
+
break;
|
|
1420
|
+
case 'prepare-guide':
|
|
1421
|
+
cmdPrepareGuide(rest);
|
|
1422
|
+
break;
|
|
1423
|
+
case 'publish-guide':
|
|
1424
|
+
await cmdPublishGuide(rest);
|
|
1425
|
+
break;
|
|
1426
|
+
case 'compose-art-cover':
|
|
1427
|
+
cmdComposeArtCover(rest);
|
|
1428
|
+
break;
|
|
1330
1429
|
case 'backfill-covers':
|
|
1331
1430
|
cmdBackfillCovers(rest);
|
|
1332
1431
|
break;
|
package/config.example.json
CHANGED
|
@@ -165,16 +165,16 @@ site, not a marketing graphic and not a repeated template.
|
|
|
165
165
|
|
|
166
166
|
### Palette
|
|
167
167
|
|
|
168
|
-
|
|
169
|
-
- **
|
|
170
|
-
|
|
171
|
-
- **Dim** `#6E675C` — secondary text
|
|
172
|
-
- **
|
|
173
|
-
|
|
174
|
-
- **
|
|
175
|
-
- **
|
|
176
|
-
|
|
177
|
-
|
|
168
|
+
<!-- >>> press:palette v0.9.0 sha256:5904c52d4168 GENERATED by @natjswenson/press, do not edit -->
|
|
169
|
+
- **Paper** `#F5F0E6` — Warm cream. Flat — never gradiented, never textured.
|
|
170
|
+
- **Ink** `#181510` — Near-black. Text, headlines, and every structural rule.
|
|
171
|
+
- **Dim** `#6E675C` — Muted secondary text; the serif commentary voice's color.
|
|
172
|
+
- **Accent** `#E8501F` — THE one loud color. Spent once or twice per document, never as decoration.
|
|
173
|
+
- **Ink Mid** `#4A423A` — Mid ink step: a second series, a stacked-bar segment, a bar track.
|
|
174
|
+
- **Ink Faint** `#8A8272` — Decorative only — a faint element inside an illustration. Never body text, never a headline.
|
|
175
|
+
- **Terminal panel** (bg, text, dim, hot, prompt) `#141A26`, `#EFE9DC`, `#8A8478`, `#FF8A5C`, `#1E2738` — the one place the dark palette
|
|
176
|
+
survives, and only inside a terminal element. Never on paper.
|
|
177
|
+
<!-- <<< press:palette -->
|
|
178
178
|
|
|
179
179
|
Prefer flat, limited color and solid/line fills over gradients or smooth shading — the
|
|
180
180
|
render is compressed with lossy PNG palette quantization afterward, and gradients band
|
|
@@ -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,11 +29,13 @@ 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 }),
|
|
35
36
|
gitAuthor: (c, v) => ({ ...c, gitAuthor: v }),
|
|
36
37
|
githubUser: (c, v) => ({ ...c, githubUser: v }),
|
|
38
|
+
siteUrl: (c, v) => (v === '' ? omit(c, 'siteUrl') : { ...c, siteUrl: v.replace(/\/+$/, '') }),
|
|
37
39
|
voicePath: (c, v) => (v === '' ? omit(c, 'voicePath') : { ...c, voicePath: expandHome(v) }),
|
|
38
40
|
'deepDive.minSources': (c, v) => {
|
|
39
41
|
const n = Number(v);
|
package/lib/core.mjs
CHANGED
|
@@ -125,6 +125,19 @@ export function validateConfig(config) {
|
|
|
125
125
|
throw new Error(`targetDir must be a relative path like "content/devlog" (no leading/trailing slash, no '..'): got ${JSON.stringify(config.targetDir)}`);
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
|
+
if ('siteUrl' in config) {
|
|
129
|
+
// Optional: the public base URL the target repo is served at, used only to
|
|
130
|
+
// confirm a published entry actually renders. The repo name is not the site
|
|
131
|
+
// name (a repo called `example.io` can be served at `example.com`), and
|
|
132
|
+
// pushing content is not the same as the site serving it, so this is the
|
|
133
|
+
// only way a publish run can verify itself rather than assume.
|
|
134
|
+
// Interpolated into a shell curl, hence the tight charset.
|
|
135
|
+
if (typeof config.siteUrl !== 'string'
|
|
136
|
+
|| !/^https?:\/\/[A-Za-z0-9.-]+(:\d+)?(\/[A-Za-z0-9._~/-]*)?$/.test(config.siteUrl)
|
|
137
|
+
|| config.siteUrl.endsWith('/')) {
|
|
138
|
+
throw new Error(`siteUrl must be an http(s) URL with no trailing slash, e.g. "https://example.com": got ${JSON.stringify(config.siteUrl)}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
128
141
|
if ('voicePath' in config) {
|
|
129
142
|
// Optional: directory holding the voice profile used to write entries. Read by
|
|
130
143
|
// the skill with the Read tool only — never shell-interpolated — so the only
|
|
@@ -137,6 +150,9 @@ export function validateConfig(config) {
|
|
|
137
150
|
throw new Error(`voicePath must be a path with no shell metacharacters and no leading dash: got ${JSON.stringify(config.voicePath)}`);
|
|
138
151
|
}
|
|
139
152
|
}
|
|
153
|
+
if ('generationMode' in config && !['release', 'concept'].includes(config.generationMode)) {
|
|
154
|
+
throw new Error('generationMode must be release or concept');
|
|
155
|
+
}
|
|
140
156
|
if ('deepDive' in config) {
|
|
141
157
|
const d = config.deepDive;
|
|
142
158
|
if (!d || typeof d !== 'object' || Array.isArray(d)) throw new Error('deepDive must be an object');
|