@natjswenson/devlog 0.1.8 → 0.3.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 +63 -0
- package/README.md +35 -20
- package/SKILL.md +224 -73
- package/bin/devlog.js +140 -44
- package/config.example.json +9 -4
- package/examples/react/DevLogPage.css +23 -15
- package/examples/react/DevLogPage.jsx +27 -4
- package/examples/react/README.md +1 -1
- package/examples/react/useDevLogEntries.js +10 -6
- package/package.json +6 -3
- package/preview/demo.js +32 -25
- package/voice/voice-notes.example.md +16 -0
- package/voice/voice-profile.example.md +37 -0
package/bin/devlog.js
CHANGED
|
@@ -19,13 +19,19 @@ import kleur from 'kleur';
|
|
|
19
19
|
//
|
|
20
20
|
// For strict-token fields (project keys, repo names, branch names), separate
|
|
21
21
|
// allowlist regexes apply additional structural constraints.
|
|
22
|
-
const SHELL_QUOTE_BREAK = /[;&|`$()<>{}[\]*?!#~"'\\\n\r]/;
|
|
23
|
-
const RE_GH_USER = /^[a-z0-9][a-z0-9-]*$/i;
|
|
24
|
-
const RE_REPO_NAME = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
25
|
-
const RE_OWNER_REPO = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i;
|
|
26
|
-
const RE_PROJECT_KEY = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
27
|
-
const RE_BRANCH = /^[a-z0-9][a-z0-9._/-]*$/i;
|
|
28
|
-
|
|
22
|
+
export const SHELL_QUOTE_BREAK = /[;&|`$()<>{}[\]*?!#~"'\\\n\r]/;
|
|
23
|
+
export const RE_GH_USER = /^[a-z0-9][a-z0-9-]*$/i;
|
|
24
|
+
export const RE_REPO_NAME = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
25
|
+
export const RE_OWNER_REPO = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i;
|
|
26
|
+
export const RE_PROJECT_KEY = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
27
|
+
export const RE_BRANCH = /^[a-z0-9][a-z0-9._/-]*$/i;
|
|
28
|
+
// Repo-relative subdir used to scope `git log` to one skill in a monorepo.
|
|
29
|
+
// Same shape as a branch: no leading dash/slash, no shell metacharacters.
|
|
30
|
+
export const RE_PATH_FILTER = /^[a-z0-9][a-z0-9._/-]*$/i;
|
|
31
|
+
// Git tag prefix that marks a project's releases (e.g. `v` or `devlog-v`).
|
|
32
|
+
// Interpolated into `git tag --list '<tagPrefix>*'`; same safety as a path filter.
|
|
33
|
+
export const RE_TAG_PREFIX = /^[a-z0-9][a-z0-9._/-]*$/i;
|
|
34
|
+
export const FORBIDDEN_BRANCH_PARTS = /(^|\/)\.\.($|\/)/; // reject `..` as a path component
|
|
29
35
|
|
|
30
36
|
const require = createRequire(import.meta.url);
|
|
31
37
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -34,6 +40,9 @@ const CONFIG_DIR = join(homedir(), '.claude', 'skills', 'devlog');
|
|
|
34
40
|
const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
|
|
35
41
|
const SKILL_DEST = join(CONFIG_DIR, 'SKILL.md');
|
|
36
42
|
const PREVIEW_DIR = join(PACKAGE_ROOT, 'preview');
|
|
43
|
+
const VOICE_SRC_DIR = join(PACKAGE_ROOT, 'voice');
|
|
44
|
+
const VOICE_DEST_DIR = join(CONFIG_DIR, 'voice');
|
|
45
|
+
const GHOSTWRITER_VOICE_DIR = join(homedir(), '.claude', 'skills', 'ghostwriter', 'voice');
|
|
37
46
|
|
|
38
47
|
const log = {
|
|
39
48
|
info: (msg) => console.log(msg),
|
|
@@ -69,7 +78,7 @@ function tryExecArgs(cmd, args) {
|
|
|
69
78
|
}
|
|
70
79
|
}
|
|
71
80
|
|
|
72
|
-
function expandHome(p) {
|
|
81
|
+
export function expandHome(p) {
|
|
73
82
|
if (!p) return p;
|
|
74
83
|
if (p === '~') return homedir();
|
|
75
84
|
if (p.startsWith('~/')) return join(homedir(), p.slice(2));
|
|
@@ -93,7 +102,7 @@ function atomicWriteJSON(path, data) {
|
|
|
93
102
|
}
|
|
94
103
|
|
|
95
104
|
// Validate a config object before writing. Throws with a user-facing message on failure.
|
|
96
|
-
function validateConfig(config) {
|
|
105
|
+
export function validateConfig(config) {
|
|
97
106
|
if (!config || typeof config !== 'object') throw new Error('Config must be an object');
|
|
98
107
|
const required = ['targetRepo', 'gitAuthor', 'githubUser', 'projects'];
|
|
99
108
|
for (const k of required) {
|
|
@@ -113,6 +122,18 @@ function validateConfig(config) {
|
|
|
113
122
|
throw new Error(`branch must be a valid git branch name (no leading dash, no '..'): got ${JSON.stringify(config.branch)}`);
|
|
114
123
|
}
|
|
115
124
|
}
|
|
125
|
+
if ('voicePath' in config) {
|
|
126
|
+
// Optional: directory holding the voice profile used to write entries. Read by
|
|
127
|
+
// the skill with the Read tool only — never shell-interpolated — so the only
|
|
128
|
+
// hard requirement is no shell metacharacters and no leading dash. A leading `~`
|
|
129
|
+
// is allowed (the skill expands it); we test the expanded form so an absolute
|
|
130
|
+
// path has no `~` left to trip the shell-quote-break check. Existence is checked
|
|
131
|
+
// at prompt time (and at runtime, with a fallback chain), not here.
|
|
132
|
+
const expanded = typeof config.voicePath === 'string' ? expandHome(config.voicePath) : config.voicePath;
|
|
133
|
+
if (typeof config.voicePath !== 'string' || SHELL_QUOTE_BREAK.test(expanded) || expanded.trim().startsWith('-')) {
|
|
134
|
+
throw new Error(`voicePath must be a path with no shell metacharacters and no leading dash: got ${JSON.stringify(config.voicePath)}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
116
137
|
if (!Array.isArray(config.projects)) {
|
|
117
138
|
throw new Error('projects must be an array');
|
|
118
139
|
}
|
|
@@ -130,6 +151,22 @@ function validateConfig(config) {
|
|
|
130
151
|
if (!RE_OWNER_REPO.test(p.remote)) {
|
|
131
152
|
throw new Error(`project.remote must match <owner>/<repo>: ${JSON.stringify(p.remote)}`);
|
|
132
153
|
}
|
|
154
|
+
if ('pathFilter' in p) {
|
|
155
|
+
// Optional: scope this project's commits to a repo subdirectory (e.g. a
|
|
156
|
+
// single skill in a monorepo). Interpolated into `git log -- <pathFilter>`,
|
|
157
|
+
// so enforce the same no-metacharacter / no-`..` safety as branch names.
|
|
158
|
+
if (typeof p.pathFilter !== 'string' || !RE_PATH_FILTER.test(p.pathFilter) || FORBIDDEN_BRANCH_PARTS.test(p.pathFilter)) {
|
|
159
|
+
throw new Error(`project.pathFilter must be a repo-relative subdir (no leading dash/slash, no '..', no shell metacharacters): ${JSON.stringify(p.pathFilter)}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if ('tagPrefix' in p) {
|
|
163
|
+
// Optional: the prefix of the git tags that mark this project's releases
|
|
164
|
+
// (e.g. `devlog-v`). Interpolated into `git tag --list '<tagPrefix>*'`, so
|
|
165
|
+
// enforce the same no-metacharacter / no-`..` safety as path filters.
|
|
166
|
+
if (typeof p.tagPrefix !== 'string' || !RE_TAG_PREFIX.test(p.tagPrefix) || FORBIDDEN_BRANCH_PARTS.test(p.tagPrefix)) {
|
|
167
|
+
throw new Error(`project.tagPrefix must be a tag prefix (no leading dash/slash, no '..', no shell metacharacters): ${JSON.stringify(p.tagPrefix)}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
133
170
|
if ('label' in p) {
|
|
134
171
|
// Label is rendered as React text content only — never shell-interpolated,
|
|
135
172
|
// never used in URLs, never used as a filesystem path. React escapes all
|
|
@@ -197,7 +234,7 @@ async function confirmOverwrite(label, path) {
|
|
|
197
234
|
}
|
|
198
235
|
|
|
199
236
|
// ─── prompt validators (reused across init and add-project) ──────────────────
|
|
200
|
-
const VALIDATORS = {
|
|
237
|
+
export const VALIDATORS = {
|
|
201
238
|
gitAuthor: (v) => {
|
|
202
239
|
if (v.trim().length === 0) return 'Required';
|
|
203
240
|
if (SHELL_QUOTE_BREAK.test(v)) return 'Invalid characters (no quotes, backticks, dollar signs, semicolons, parens, or shell metacharacters)';
|
|
@@ -217,6 +254,20 @@ const VALIDATORS = {
|
|
|
217
254
|
return true;
|
|
218
255
|
},
|
|
219
256
|
ownerRepo: (v) => RE_OWNER_REPO.test(v.trim()) || 'Expected <owner>/<repo>, no leading dash, alphanumeric + ._- only',
|
|
257
|
+
voicePath: (v) => {
|
|
258
|
+
// Optional. Blank means "use ghostwriter's voice dir if present, else the bundled default".
|
|
259
|
+
if (!v || v.trim() === '') return true;
|
|
260
|
+
if (SHELL_QUOTE_BREAK.test(v)) return 'Invalid characters (no quotes, backticks, dollar signs, semicolons, parens, or shell metacharacters)';
|
|
261
|
+
if (v.trim().startsWith('-')) return 'Path cannot start with a dash';
|
|
262
|
+
return existsSync(expandHome(v.trim())) || 'Path does not exist';
|
|
263
|
+
},
|
|
264
|
+
tagPrefix: (v) => {
|
|
265
|
+
// Optional. Blank/`v` is the default. Used in `git tag --list '<prefix>*'`.
|
|
266
|
+
const t = (v || '').trim();
|
|
267
|
+
if (t === '') return true;
|
|
268
|
+
if (!RE_TAG_PREFIX.test(t) || FORBIDDEN_BRANCH_PARTS.test(t)) return 'Invalid prefix (no leading dash/slash, no "..", no shell metacharacters)';
|
|
269
|
+
return true;
|
|
270
|
+
},
|
|
220
271
|
label: (v) => {
|
|
221
272
|
// Label is React text content only — apostrophes and most punctuation are fine.
|
|
222
273
|
// Reject only control chars and overlong values.
|
|
@@ -262,6 +313,13 @@ async function promptForProject(defaults = {}) {
|
|
|
262
313
|
initial: (_p, values) => detectProjectRemote(expandHome(values.path)) || initialRemote,
|
|
263
314
|
validate: VALIDATORS.ownerRepo,
|
|
264
315
|
},
|
|
316
|
+
{
|
|
317
|
+
type: 'text',
|
|
318
|
+
name: 'tagPrefix',
|
|
319
|
+
message: 'Release tag prefix (optional, e.g. "v" or "myproject-v"):',
|
|
320
|
+
initial: defaults.tagPrefix || 'v',
|
|
321
|
+
validate: VALIDATORS.tagPrefix,
|
|
322
|
+
},
|
|
265
323
|
], { onCancel: () => process.exit(1) });
|
|
266
324
|
|
|
267
325
|
const out = {
|
|
@@ -270,6 +328,9 @@ async function promptForProject(defaults = {}) {
|
|
|
270
328
|
remote: answers.remote.trim(),
|
|
271
329
|
};
|
|
272
330
|
if (answers.label && answers.label.trim()) out.label = answers.label.trim();
|
|
331
|
+
// Only persist tagPrefix when it differs from the default `v` (keeps configs clean).
|
|
332
|
+
const tagPrefix = (answers.tagPrefix || '').trim();
|
|
333
|
+
if (tagPrefix && tagPrefix !== 'v') out.tagPrefix = tagPrefix;
|
|
273
334
|
return out;
|
|
274
335
|
}
|
|
275
336
|
|
|
@@ -282,12 +343,16 @@ async function cmdInit() {
|
|
|
282
343
|
gitAuthor: detectGitName() || '',
|
|
283
344
|
githubUser: detectGhUser() || '',
|
|
284
345
|
targetRepoName: 'daily-dev-log',
|
|
346
|
+
// Pre-fill the voice path with ghostwriter's voice dir if it's installed — that's
|
|
347
|
+
// the most likely place a user already keeps their voice profile.
|
|
348
|
+
voicePath: existsSync(GHOSTWRITER_VOICE_DIR) ? GHOSTWRITER_VOICE_DIR : '',
|
|
285
349
|
};
|
|
286
350
|
|
|
287
351
|
const answers = await prompts([
|
|
288
|
-
{ type: 'text', name: 'gitAuthor', message: 'Your name (
|
|
352
|
+
{ type: 'text', name: 'gitAuthor', message: 'Your name (retained for backward compatibility; not currently rendered on entries):', initial: defaults.gitAuthor, validate: VALIDATORS.gitAuthor },
|
|
289
353
|
{ type: 'text', name: 'githubUser', message: 'Your GitHub username:', initial: defaults.githubUser, validate: VALIDATORS.githubUser },
|
|
290
354
|
{ type: 'text', name: 'targetRepoName', message: 'Name of the repo where dev logs will be published:', initial: defaults.targetRepoName, validate: VALIDATORS.targetRepoName },
|
|
355
|
+
{ type: 'text', name: 'voicePath', message: 'Voice profile directory (optional — blank uses ghostwriter\'s if present, else the bundled default):', initial: defaults.voicePath, validate: VALIDATORS.voicePath },
|
|
291
356
|
], { onCancel: () => process.exit(1) });
|
|
292
357
|
|
|
293
358
|
// Optionally register projects in a loop. First time defaults to "yes".
|
|
@@ -313,11 +378,16 @@ async function cmdInit() {
|
|
|
313
378
|
}
|
|
314
379
|
|
|
315
380
|
const targetRepo = `${answers.githubUser}/${answers.targetRepoName}`;
|
|
381
|
+
// Store the expanded absolute path (consistent with project.path) so the persisted
|
|
382
|
+
// config never carries a `~` that would later trip the shell-quote-break check.
|
|
383
|
+
const rawVoicePath = (answers.voicePath || '').trim();
|
|
384
|
+
const voicePath = rawVoicePath ? expandHome(rawVoicePath) : '';
|
|
316
385
|
const config = validateConfig({
|
|
317
386
|
targetRepo,
|
|
318
387
|
branch: 'main',
|
|
319
388
|
gitAuthor: answers.gitAuthor,
|
|
320
389
|
githubUser: answers.githubUser,
|
|
390
|
+
...(voicePath ? { voicePath } : {}),
|
|
321
391
|
projects,
|
|
322
392
|
});
|
|
323
393
|
|
|
@@ -334,6 +404,7 @@ async function cmdInit() {
|
|
|
334
404
|
log.info(` Git author: ${config.gitAuthor}`);
|
|
335
405
|
log.info(` GitHub user: ${config.githubUser}`);
|
|
336
406
|
log.info(` Branch: ${config.branch}`);
|
|
407
|
+
log.info(` Voice profile: ${config.voicePath || '(ghostwriter if present, else bundled default)'}`);
|
|
337
408
|
log.info(` Projects: ${config.projects.length === 0 ? '(none — add later with `devlog add-project`)' : config.projects.map((p) => p.key).join(', ')}`);
|
|
338
409
|
log.info(` Skill location: ${CONFIG_DIR}`);
|
|
339
410
|
|
|
@@ -347,7 +418,7 @@ async function cmdInit() {
|
|
|
347
418
|
log.warn(`Repo github.com/${targetRepo} already exists. Will use it as-is.`);
|
|
348
419
|
} else {
|
|
349
420
|
log.step(`Creating github.com/${targetRepo}...`);
|
|
350
|
-
const r = spawnSync('gh', ['repo', 'create', targetRepo, '--public', '--description', '
|
|
421
|
+
const r = spawnSync('gh', ['repo', 'create', targetRepo, '--public', '--description', 'Release dev log', '--add-readme'], { stdio: 'inherit' });
|
|
351
422
|
if (r.status !== 0) {
|
|
352
423
|
log.err('Failed to create repo. Check `gh` permissions.');
|
|
353
424
|
process.exit(1);
|
|
@@ -374,16 +445,33 @@ async function cmdInit() {
|
|
|
374
445
|
log.warn('Skipped config.json');
|
|
375
446
|
}
|
|
376
447
|
|
|
448
|
+
// Install the bundled voice template as the fallback voice profile. The skill
|
|
449
|
+
// resolves voice in this order: config.voicePath → ghostwriter's voice dir →
|
|
450
|
+
// this bundled copy. Installing it guarantees the last fallback always exists.
|
|
451
|
+
if (!existsSync(VOICE_DEST_DIR)) {
|
|
452
|
+
mkdirSync(VOICE_DEST_DIR, { recursive: true, mode: 0o700 });
|
|
453
|
+
}
|
|
454
|
+
for (const [src, dest] of [['voice-profile.example.md', 'voice-profile.md'], ['voice-notes.example.md', 'voice-notes.md']]) {
|
|
455
|
+
const s = join(VOICE_SRC_DIR, src);
|
|
456
|
+
const d = join(VOICE_DEST_DIR, dest);
|
|
457
|
+
if (existsSync(s) && (await confirmOverwrite(`voice/${dest}`, d))) {
|
|
458
|
+
copyFileSync(s, d);
|
|
459
|
+
log.ok(`Installed voice/${dest} → ${d}`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
377
463
|
log.info('\n' + kleur.bold().green('Setup complete.') + '\n');
|
|
378
464
|
log.info('Next steps:');
|
|
379
465
|
if (config.projects.length === 0) {
|
|
380
466
|
log.info(` 1. Add a project: ${kleur.cyan('npx @natjswenson/devlog add-project')}`);
|
|
381
|
-
log.info(' 2.
|
|
467
|
+
log.info(' 2. Tag a release in the project (e.g. `git tag v0.1.0`)');
|
|
468
|
+
log.info(` 3. In Claude Code, run: ${kleur.cyan('/devlog')}`);
|
|
469
|
+
log.info(` 4. Preview locally: ${kleur.cyan('npx @natjswenson/devlog preview')}`);
|
|
382
470
|
} else {
|
|
383
|
-
log.info(' 1.
|
|
471
|
+
log.info(' 1. Tag a release in a registered project (e.g. `git tag v0.1.0`)');
|
|
472
|
+
log.info(` 2. In Claude Code, run: ${kleur.cyan('/devlog')}`);
|
|
473
|
+
log.info(` 3. Preview locally: ${kleur.cyan('npx @natjswenson/devlog preview')}`);
|
|
384
474
|
}
|
|
385
|
-
log.info(` 2. In Claude Code, run: ${kleur.cyan('/devlog')}`);
|
|
386
|
-
log.info(` 3. Preview locally: ${kleur.cyan('npx @natjswenson/devlog preview')}`);
|
|
387
475
|
log.info('');
|
|
388
476
|
}
|
|
389
477
|
|
|
@@ -458,11 +546,14 @@ async function cmdConfig() {
|
|
|
458
546
|
log.info(`Branch: ${config.branch || 'main'}`);
|
|
459
547
|
log.info(`Git author: ${config.gitAuthor || '?'}`);
|
|
460
548
|
log.info(`GitHub user: ${config.githubUser || '?'}`);
|
|
549
|
+
log.info(`Voice path: ${config.voicePath || kleur.dim('(ghostwriter if present, else bundled default)')}`);
|
|
461
550
|
log.info(`Projects (${(config.projects || []).length}):`);
|
|
462
551
|
for (const p of config.projects || []) {
|
|
463
552
|
log.info(` ${kleur.cyan(p.key)}${p.label ? ` (${p.label})` : ''}`);
|
|
464
553
|
log.info(kleur.dim(` path: ${p.path}`));
|
|
465
554
|
log.info(kleur.dim(` remote: github.com/${p.remote}`));
|
|
555
|
+
if (p.pathFilter) log.info(kleur.dim(` scope: ${p.pathFilter}/`));
|
|
556
|
+
log.info(kleur.dim(` tags: ${p.tagPrefix || 'v'}*`));
|
|
466
557
|
}
|
|
467
558
|
log.info('');
|
|
468
559
|
}
|
|
@@ -526,7 +617,7 @@ async function cmdPreview() {
|
|
|
526
617
|
// ─── help ────────────────────────────────────────────────────────────────────
|
|
527
618
|
function printHelp() {
|
|
528
619
|
console.log(`
|
|
529
|
-
${kleur.bold('@natjswenson/devlog')} v${readPackageVersion()} —
|
|
620
|
+
${kleur.bold('@natjswenson/devlog')} v${readPackageVersion()} — release dev log generator
|
|
530
621
|
|
|
531
622
|
Usage:
|
|
532
623
|
${kleur.cyan('npx @natjswenson/devlog init')} One-time setup: create your dev-log repo, install the skill, write config
|
|
@@ -542,31 +633,36 @@ Issues: https://github.com/natejswenson/devlog/issues
|
|
|
542
633
|
}
|
|
543
634
|
|
|
544
635
|
// ─── dispatch ────────────────────────────────────────────────────────────────
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
636
|
+
// Only run the CLI dispatch when this file is executed directly, not when it is
|
|
637
|
+
// imported (e.g. by the test suite). Importing the module must have no side effects.
|
|
638
|
+
const isMain = process.argv[1] === fileURLToPath(import.meta.url);
|
|
639
|
+
if (isMain) {
|
|
640
|
+
const arg = process.argv[2];
|
|
641
|
+
switch (arg) {
|
|
642
|
+
case 'init':
|
|
643
|
+
cmdInit();
|
|
644
|
+
break;
|
|
645
|
+
case 'add-project':
|
|
646
|
+
cmdAddProject();
|
|
647
|
+
break;
|
|
648
|
+
case 'config':
|
|
649
|
+
cmdConfig();
|
|
650
|
+
break;
|
|
651
|
+
case 'preview':
|
|
652
|
+
cmdPreview();
|
|
653
|
+
break;
|
|
654
|
+
case '-v':
|
|
655
|
+
case '--version':
|
|
656
|
+
console.log(readPackageVersion());
|
|
657
|
+
break;
|
|
658
|
+
case undefined:
|
|
659
|
+
case '-h':
|
|
660
|
+
case '--help':
|
|
661
|
+
printHelp();
|
|
662
|
+
break;
|
|
663
|
+
default:
|
|
664
|
+
log.err(`Unknown command: ${arg}`);
|
|
665
|
+
printHelp();
|
|
666
|
+
process.exit(1);
|
|
667
|
+
}
|
|
572
668
|
}
|
package/config.example.json
CHANGED
|
@@ -3,17 +3,22 @@
|
|
|
3
3
|
"branch": "main",
|
|
4
4
|
"gitAuthor": "Your Name",
|
|
5
5
|
"githubUser": "yourusername",
|
|
6
|
+
"voicePath": "~/.claude/skills/ghostwriter/voice",
|
|
6
7
|
"projects": [
|
|
7
8
|
{
|
|
8
9
|
"key": "midnight-side-quest",
|
|
9
10
|
"label": "Midnight Side Quest",
|
|
10
11
|
"path": "/Users/yourusername/code/midnight-side-quest",
|
|
11
|
-
"remote": "yourusername/midnight-side-quest"
|
|
12
|
+
"remote": "yourusername/midnight-side-quest",
|
|
13
|
+
"tagPrefix": "v"
|
|
12
14
|
},
|
|
13
15
|
{
|
|
14
|
-
"key": "
|
|
15
|
-
"
|
|
16
|
-
"
|
|
16
|
+
"key": "devlog",
|
|
17
|
+
"label": "Devlog",
|
|
18
|
+
"path": "/Users/yourusername/code/your-monorepo",
|
|
19
|
+
"remote": "yourusername/your-monorepo",
|
|
20
|
+
"pathFilter": "skills/devlog",
|
|
21
|
+
"tagPrefix": "devlog-v"
|
|
17
22
|
}
|
|
18
23
|
]
|
|
19
24
|
}
|
|
@@ -75,33 +75,39 @@
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
.devlog-entry {
|
|
78
|
-
padding: 28px 0;
|
|
79
78
|
border-bottom: 1px solid var(--devlog-border);
|
|
80
|
-
cursor: pointer;
|
|
81
|
-
transition: background var(--devlog-transition-fast);
|
|
82
79
|
}
|
|
83
80
|
|
|
84
81
|
.devlog-entry:first-child {
|
|
85
82
|
border-top: 1px solid var(--devlog-border);
|
|
86
83
|
}
|
|
87
84
|
|
|
88
|
-
|
|
89
|
-
background: var(--devlog-bg-surface);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
.devlog-entry--expanded,
|
|
93
|
-
.devlog-entry--expanded:hover {
|
|
94
|
-
cursor: default;
|
|
95
|
-
background: none;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/* ─── Entry header ──────────────────────────────────────────────────── */
|
|
85
|
+
/* ─── Entry header (the disclosure control) ─────────────────────────── */
|
|
99
86
|
|
|
100
87
|
.devlog-header {
|
|
101
88
|
display: flex;
|
|
102
89
|
justify-content: space-between;
|
|
103
90
|
align-items: flex-start;
|
|
104
91
|
gap: 24px;
|
|
92
|
+
padding: 28px 0;
|
|
93
|
+
cursor: pointer;
|
|
94
|
+
transition: background var(--devlog-transition-fast);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.devlog-header:hover {
|
|
98
|
+
background: var(--devlog-bg-surface);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.devlog-header:focus-visible {
|
|
102
|
+
outline: 2px solid var(--devlog-fg);
|
|
103
|
+
outline-offset: -2px;
|
|
104
|
+
border-radius: 4px;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.devlog-entry--expanded .devlog-header,
|
|
108
|
+
.devlog-entry--expanded .devlog-header:hover {
|
|
109
|
+
cursor: default;
|
|
110
|
+
background: none;
|
|
105
111
|
}
|
|
106
112
|
|
|
107
113
|
.devlog-header__left {
|
|
@@ -163,8 +169,10 @@
|
|
|
163
169
|
overflow: hidden;
|
|
164
170
|
}
|
|
165
171
|
|
|
172
|
+
/* Header already contributes 28px of bottom padding above this region,
|
|
173
|
+
so the expanded body sits flush against it. */
|
|
166
174
|
.devlog-content {
|
|
167
|
-
padding-top:
|
|
175
|
+
padding-top: 0;
|
|
168
176
|
}
|
|
169
177
|
|
|
170
178
|
.devlog-content h2 {
|
|
@@ -81,6 +81,15 @@ export default function DevLogPage({
|
|
|
81
81
|
}
|
|
82
82
|
}, [expandedEntry, loadedContent, fetchEntryContent]);
|
|
83
83
|
|
|
84
|
+
// Enter/Space toggle the focused entry — keyboard parity with the click
|
|
85
|
+
// handler. preventDefault on Space stops the page from scrolling.
|
|
86
|
+
const handleKeyDown = useCallback((e, filename) => {
|
|
87
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
handleToggle(filename);
|
|
90
|
+
}
|
|
91
|
+
}, [handleToggle]);
|
|
92
|
+
|
|
84
93
|
const visibleEntries = entries.slice(0, visibleCount);
|
|
85
94
|
const hasMore = visibleCount < entries.length;
|
|
86
95
|
const showTabs = projects && projects.length > 1;
|
|
@@ -132,14 +141,28 @@ export default function DevLogPage({
|
|
|
132
141
|
{visibleEntries.map((entry) => {
|
|
133
142
|
const isExpanded = expandedEntry === entry.file;
|
|
134
143
|
const content = loadedContent.get(entry.file);
|
|
144
|
+
const contentId = `devlog-content-${entry.file}`;
|
|
135
145
|
|
|
136
146
|
return (
|
|
137
147
|
<article
|
|
138
148
|
key={entry.file}
|
|
139
149
|
className={`devlog-entry${isExpanded ? ' devlog-entry--expanded' : ''}`}
|
|
140
|
-
onClick={() => handleToggle(entry.file)}
|
|
141
150
|
>
|
|
142
|
-
|
|
151
|
+
{/* The header is the disclosure control: role=button +
|
|
152
|
+
aria-expanded/aria-controls give screen readers the
|
|
153
|
+
toggle semantics, and it's keyboard-focusable. Keeping
|
|
154
|
+
it separate from the content region (rather than wrapping
|
|
155
|
+
the whole card in onClick) means links inside an expanded
|
|
156
|
+
entry aren't trapped inside an interactive ancestor. */}
|
|
157
|
+
<div
|
|
158
|
+
className="devlog-header"
|
|
159
|
+
role="button"
|
|
160
|
+
tabIndex={0}
|
|
161
|
+
aria-expanded={isExpanded}
|
|
162
|
+
aria-controls={contentId}
|
|
163
|
+
onClick={() => handleToggle(entry.file)}
|
|
164
|
+
onKeyDown={(e) => handleKeyDown(e, entry.file)}
|
|
165
|
+
>
|
|
143
166
|
<div className="devlog-header__left">
|
|
144
167
|
<p className="devlog-date">{formatDate(entry.date)}</p>
|
|
145
168
|
<h2 className="devlog-title">{entry.title}</h2>
|
|
@@ -150,10 +173,10 @@ export default function DevLogPage({
|
|
|
150
173
|
</div>
|
|
151
174
|
</div>
|
|
152
175
|
|
|
153
|
-
<div className="devlog-content-wrapper">
|
|
176
|
+
<div className="devlog-content-wrapper" id={contentId}>
|
|
154
177
|
<div className="devlog-content-inner">
|
|
155
178
|
{isExpanded && content && (
|
|
156
|
-
<div className="devlog-content"
|
|
179
|
+
<div className="devlog-content">
|
|
157
180
|
<ReactMarkdown
|
|
158
181
|
remarkPlugins={[remarkGfm]}
|
|
159
182
|
urlTransform={safeUrlTransform}
|
package/examples/react/README.md
CHANGED
|
@@ -3,11 +3,12 @@ import { DEVLOG_CONFIG as DEFAULT_CONFIG } from './devlog-config.js';
|
|
|
3
3
|
|
|
4
4
|
// Allowlist of frontmatter keys we recognize. Anything else is ignored —
|
|
5
5
|
// prevents prototype-pollution via crafted keys like `__proto__`.
|
|
6
|
-
|
|
6
|
+
// `version` is the release tag this entry corresponds to (e.g. "v0.2.0").
|
|
7
|
+
const FRONTMATTER_KEYS = new Set(['title', 'date', 'project', 'summary', 'version']);
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Parse YAML-ish frontmatter from a markdown string.
|
|
10
|
-
* Returns { metadata: { title, date, project, summary }, body: string }
|
|
11
|
+
* Returns { metadata: { title, date, project, summary, version }, body: string }
|
|
11
12
|
*/
|
|
12
13
|
function parseFrontmatter(raw) {
|
|
13
14
|
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
@@ -24,19 +25,22 @@ function parseFrontmatter(raw) {
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
// Schema validation for fetched manifest. Reject anything that isn't shaped
|
|
27
|
-
// like { entries: [{ date, file, title, summary }, ...] } so a hostile
|
|
28
|
-
// to the dev-log repo can't crash the page.
|
|
28
|
+
// like { entries: [{ date, file, title, summary, version? }, ...] } so a hostile
|
|
29
|
+
// commit to the dev-log repo can't crash the page. `version` is optional and
|
|
30
|
+
// only kept when it's a clean tag-ish string.
|
|
29
31
|
function validateManifest(data) {
|
|
30
32
|
if (!data || typeof data !== 'object') return null;
|
|
31
33
|
if (!Array.isArray(data.entries)) return null;
|
|
32
34
|
const entries = [];
|
|
33
35
|
for (const e of data.entries) {
|
|
34
36
|
if (!e || typeof e !== 'object') continue;
|
|
35
|
-
const { date, file, title, summary } = e;
|
|
37
|
+
const { date, file, title, summary, version } = e;
|
|
36
38
|
if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) continue;
|
|
37
39
|
if (typeof file !== 'string' || !/^[a-zA-Z0-9._-]+\.md$/.test(file)) continue;
|
|
38
40
|
if (typeof title !== 'string' || typeof summary !== 'string') continue;
|
|
39
|
-
|
|
41
|
+
const entry = { date, file, title, summary };
|
|
42
|
+
if (typeof version === 'string' && /^[a-zA-Z0-9._-]+$/.test(version)) entry.version = version;
|
|
43
|
+
entries.push(entry);
|
|
40
44
|
}
|
|
41
45
|
return { entries };
|
|
42
46
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@natjswenson/devlog",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Release dev log generator — 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",
|
|
7
7
|
"homepage": "https://github.com/natejswenson/devlog",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"claude-skill",
|
|
19
19
|
"build-in-public",
|
|
20
20
|
"git",
|
|
21
|
+
"release-notes",
|
|
21
22
|
"blog"
|
|
22
23
|
],
|
|
23
24
|
"type": "module",
|
|
@@ -28,6 +29,7 @@
|
|
|
28
29
|
"bin/",
|
|
29
30
|
"preview/",
|
|
30
31
|
"examples/",
|
|
32
|
+
"voice/",
|
|
31
33
|
"SKILL.md",
|
|
32
34
|
"SECURITY.md",
|
|
33
35
|
"CHANGELOG.md",
|
|
@@ -39,6 +41,7 @@
|
|
|
39
41
|
"node": ">=18"
|
|
40
42
|
},
|
|
41
43
|
"scripts": {
|
|
44
|
+
"test": "node --test \"tests/**/*.test.mjs\"",
|
|
42
45
|
"audit": "npm audit --audit-level=moderate"
|
|
43
46
|
},
|
|
44
47
|
"dependencies": {
|
|
@@ -49,6 +52,6 @@
|
|
|
49
52
|
"react-dom": "18.3.1",
|
|
50
53
|
"react-markdown": "9.1.0",
|
|
51
54
|
"remark-gfm": "4.0.1",
|
|
52
|
-
"vite": "8.0.
|
|
55
|
+
"vite": "8.0.16"
|
|
53
56
|
}
|
|
54
57
|
}
|