@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/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
- const FORBIDDEN_BRANCH_PARTS = /(^|\/)\.\.($|\/)/; // reject `..` as a path component
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 (used to filter `git log --author`):', initial: defaults.gitAuthor, validate: VALIDATORS.gitAuthor },
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', 'Daily dev log', '--add-readme'], { stdio: 'inherit' });
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. Make some commits in the project');
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. Make some commits in a registered project');
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()} — daily dev log generator
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
- const arg = process.argv[2];
546
- switch (arg) {
547
- case 'init':
548
- cmdInit();
549
- break;
550
- case 'add-project':
551
- cmdAddProject();
552
- break;
553
- case 'config':
554
- cmdConfig();
555
- break;
556
- case 'preview':
557
- cmdPreview();
558
- break;
559
- case '-v':
560
- case '--version':
561
- console.log(readPackageVersion());
562
- break;
563
- case undefined:
564
- case '-h':
565
- case '--help':
566
- printHelp();
567
- break;
568
- default:
569
- log.err(`Unknown command: ${arg}`);
570
- printHelp();
571
- process.exit(1);
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
  }
@@ -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": "todays-existential-crisis",
15
- "path": "/Users/yourusername/code/todays-existential-crisis",
16
- "remote": "yourusername/todays-existential-crisis"
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
- .devlog-entry:hover {
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: 24px;
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
- <div className="devlog-header">
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" onClick={(e) => e.stopPropagation()}>
179
+ <div className="devlog-content">
157
180
  <ReactMarkdown
158
181
  remarkPlugins={[remarkGfm]}
159
182
  urlTransform={safeUrlTransform}
@@ -1,6 +1,6 @@
1
1
  # React example — devlog
2
2
 
3
- Drop-in React components for rendering your daily dev log on any React-based site.
3
+ Drop-in React components for rendering your release dev log on any React-based site.
4
4
 
5
5
  ## What's here
6
6
 
@@ -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
- const FRONTMATTER_KEYS = new Set(['title', 'date', 'project', 'summary']);
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 commit
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
- entries.push({ date, file, title, summary });
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.8",
4
- "description": "Daily dev log generator — Claude Code skill + preview app for publishing git-based dev logs to your site",
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.10"
55
+ "vite": "8.0.16"
53
56
  }
54
57
  }