Setting the file. One moment.
New Adr · Adr Skill · vercel/ai · Skills Docs
ContentsBack to the top of the page function insertIndexEntryUnderHeading
— line 227
This file
Number 4.6
Position 6 of 10
Type JavaScript
Size 12 KB
Lines 416 scripts/ new_adr.js
JavaScript · 416 lines · 12 KB
13
14 function die ( msg ) {
15 process.stderr. write ( `${ msg } \n ` );
16 process. exit ( 1 );
17 }
18
19 function slugify ( text ) {
20 const t = String (text || '' )
21 . trim ()
22 . toLowerCase ();
23 const noQuotes = t. replace ( / ['"`] / g , '' );
24 const dashed = noQuotes. replace ( / [ ^ a-z0-9] + / g , '-' ). replace ( /- {2,} / g , '-' );
25 const trimmed = dashed. replace ( / ^ - + / , '' ). replace ( /- +$ / , '' );
26 return trimmed || 'decision' ;
27 }
28
29 function toPosix ( p ) {
30 return p. split (path.sep). join ( '/' );
31 }
32
33 function parseArgs ( argv ) {
34 const out = {
35 repoRoot: '.' ,
36 dir: null ,
37 noCreateDir: false ,
38 title: null ,
39 status: 'proposed' ,
40 template: 'simple' , // simple | madr
41 strategy: 'auto' , // auto | date | slug
42 deciders: '' ,
43 consulted: '' ,
44 informed: '' ,
45 technicalStory: '' ,
46 chosenOption: '' ,
47 updateIndex: false ,
48 indexFile: null ,
49 json: false ,
50 };
51
52 for ( let i = 2 ; i < argv. length ; i ++ ) {
53 const a = argv[i];
54 const next = () => {
55 if (i + 1 >= argv. length ) die ( `Missing value for ${ a }` );
56 return argv[ ++ i];
57 };
58
59 if (a === '--repo-root' ) out.repoRoot = next ();
60 else if (a === '--dir' ) out.dir = next ();
61 else if (a === '--no-create-dir' ) out.noCreateDir = true ;
62 else if (a === '--title' ) out.title = next ();
63 else if (a === '--status' ) out.status = next ();
64 else if (a === '--template' ) out.template = next ();
65 else if (a === '--strategy' ) out.strategy = next ();
66 else if (a === '--deciders' ) out.deciders = next ();
67 else if (a === '--consulted' ) out.consulted = next ();
68 else if (a === '--informed' ) out.informed = next ();
69 else if (a === '--technical-story' ) out.technicalStory = next ();
70 else if (a === '--chosen-option' ) out.chosenOption = next ();
71 else if (a === '--update-index' ) out.updateIndex = true ;
72 else if (a === '--index-file' ) out.indexFile = next ();
73 else if (a === '--json' ) out.json = true ;
74 else if (a === '--help' || a === '-h' ) {
75 process.stdout. write (
76 [
77 'Usage: node new_adr.js --title "Choose database" [options]' ,
78 '' ,
79 'Options:' ,
80 ' --repo-root <path> Repo root (default: .)' ,
81 ' --dir <path> ADR directory (default: auto-detect, else adr/)' ,
82 ' --no-create-dir Do not create ADR directory if missing' ,
83 ' --status <value> ADR status (default: proposed)' ,
84 ' --template simple|madr Template (default: simple)' ,
85 ' --strategy auto|date|slug Filename strategy (default: auto)' ,
86 ' --deciders "a,b" Deciders list' ,
87 ' --consulted "a,b" Consulted experts (RACI)' ,
88 ' --informed "a,b" Informed stakeholders (RACI)' ,
89 ' --technical-story <x> Issue/ticket/PR link or short ref' ,
90 ' --chosen-option <x> MADR template: chosen option label' ,
91 ' --update-index Update adr/README.md (or existing index)' ,
92 ' --index-file <path> Override index file (relative to repo root unless absolute)' ,
93 ' --json Output machine-readable JSON (default: off)' ,
94 '' ,
95 ]. join ( ' \n ' ),
96 );
97 process. exit ( 0 );
98 } else {
99 die ( `Unknown arg: ${ a }` );
100 }
101 }
102
103 if ( ! out.title) die ( 'Missing required --title' );
104
105 if ( ! [ 'simple' , 'madr' ]. includes (out.template))
106 die ( `Invalid --template: ${ out . template }` );
107 if ( ! [ 'auto' , 'date' , 'slug' ]. includes (out.strategy))
108 die ( `Invalid --strategy: ${ out . strategy }` );
109
110 return out;
111 }
112
113 function detectAdrDir ( repoRoot ) {
114 const candidates = [
115 path. join (repoRoot, 'contributing' , 'decisions' ),
116 path. join (repoRoot, 'docs' , 'decisions' ),
117 path. join (repoRoot, 'adr' ),
118 path. join (repoRoot, 'docs' , 'adr' ),
119 path. join (repoRoot, 'docs' , 'adrs' ),
120 path. join (repoRoot, 'decisions' ),
121 ];
122 for ( const p of candidates) {
123 try {
124 if (fs. statSync (p). isDirectory ()) return p;
125 } catch {
126 // ignore
127 }
128 }
129 return null ;
130 }
131
132 function listMdFiles ( dir ) {
133 let entries = [];
134 try {
135 entries = fs. readdirSync (dir, { withFileTypes: true });
136 } catch {
137 return [];
138 }
139 return entries
140 . filter ( e => e. isFile () && e.name. toLowerCase (). endsWith ( '.md' ))
141 . map ( e => e.name);
142 }
143
144 function detectStrategy ( adrDir ) {
145 const md = listMdFiles (adrDir);
146 for ( const name of md) {
147 if ( / ^ \d {4} - \d {2} - \d {2} -/ . test (name)) return 'date' ;
148 }
149 if (md. length > 0 ) return 'slug' ;
150 return 'date' ;
151 }
152
153 function todayISO () {
154 return new Date (). toISOString (). slice ( 0 , 10 );
155 }
156
157 function loadTemplate ( templateName ) {
158 const skillRoot = path. resolve (__dirname, '..' );
159 const templatePath = path. join (
160 skillRoot,
161 'assets' ,
162 'templates' ,
163 `adr-${ templateName }.md` ,
164 );
165 if ( ! fs. existsSync (templatePath)) die ( `Template not found: ${ templatePath }` );
166 return fs. readFileSync (templatePath, 'utf8' );
167 }
168
169 function renderTemplate ( raw , vars ) {
170 // Handle YAML front matter placeholders (quoted and unquoted)
171 let out = raw;
172
173 // YAML front matter fields — replace the whole placeholder pattern
174 // e.g. status: "{proposed | accepted | ...}" → status: proposed
175 out = out. replace (
176 / ^ (status: \s * ) ["'] ? \{ [ ^ }] * \} ["'] ? \s *$ / m ,
177 `$1${ vars . status }` ,
178 );
179 out = out. replace ( / ^ (date: \s * ) \{ [ ^ }] * \} \s *$ / m , `$1${ vars . date }` );
180 out = out. replace (
181 / ^ (decision-makers: \s * ) ["'] ? \{ [ ^ }] * \} ["'] ? \s *$ / m ,
182 `$1${ vars . deciders || ''}` ,
183 );
184
185 // consulted / informed: replace if a value was provided, otherwise remove the
186 // entire line so we don't leak placeholder text like "{list everyone...}"
187 if (vars.consulted) {
188 out = out. replace (
189 / ^ (consulted: \s * ) ["'] ? \{ [ ^ }] * \} ["'] ? \s *$ / m ,
190 `$1${ vars . consulted }` ,
191 );
192 } else {
193 out = out. replace ( / ^ consulted: \s * ["'] ? \{ [ ^ }] * \} ["'] ? \s * \n / m , '' );
194 }
195 if (vars.informed) {
196 out = out. replace (
197 / ^ (informed: \s * ) ["'] ? \{ [ ^ }] * \} ["'] ? \s *$ / m ,
198 `$1${ vars . informed }` ,
199 );
200 } else {
201 out = out. replace ( / ^ informed: \s * ["'] ? \{ [ ^ }] * \} ["'] ? \s * \n / m , '' );
202 }
203
204 // Replace MADR-style heading placeholder
205 out = out. replace ( / ^ (# \s + ) \{ short title [ ^ }] * \} \s *$ / m , `$1${ vars . title }` );
206
207 // Inline placeholders (title in heading, etc.)
208 out = out
209 . replaceAll ( '{TITLE}' , vars.title)
210 . replaceAll ( '{STATUS}' , vars.status)
211 . replaceAll ( '{DATE}' , vars.date)
212 . replaceAll ( '{DECIDERS}' , vars.deciders)
213 . replaceAll ( '{TECHNICAL_STORY}' , vars.technicalStory)
214 . replaceAll ( '{CHOSEN_OPTION}' , vars.chosenOption);
215
216 return out;
217 }
218
219 function chooseIndexFile ( adrDir ) {
220 for ( const name of [ 'README.md' , 'index.md' ]) {
221 const p = path. join (adrDir, name);
222 if (fs. existsSync (p)) return p;
223 }
224 return path. join (adrDir, 'README.md' );
225 }
226
227 function insertIndexEntryUnderHeading ( lines , headingRegex , entryLine ) {
228 // Returns { lines, inserted }
229 const headingIndex = lines. findIndex ( l => headingRegex. test (l));
230 if (headingIndex === - 1 ) return { lines, inserted: false };
231
232 let sectionEnd = lines. length ;
233 for ( let i = headingIndex + 1 ; i < lines. length ; i ++ ) {
234 if ( / ^ ## \s + / . test (lines[i])) {
235 sectionEnd = i;
236 break ;
237 }
238 }
239
240 // Prefer inserting at end of list in this section if there is a list.
241 let lastListItem = - 1 ;
242 for ( let i = sectionEnd - 1 ; i > headingIndex; i -- ) {
243 if ( / ^ [-*]\s + / . test (lines[i])) {
244 lastListItem = i;
245 break ;
246 }
247 }
248
249 const insertAt = lastListItem !== - 1 ? lastListItem + 1 : sectionEnd;
250
251 const out = [ ... lines];
252
253 // Ensure there's a blank line after the heading if we're inserting immediately after it.
254 if (insertAt === headingIndex + 1 && out[insertAt] !== '' ) {
255 out. splice (insertAt, 0 , '' );
256 }
257
258 out. splice (insertAt, 0 , entryLine);
259 return { lines: out, inserted: true };
260 }
261
262 function updateIndex ( indexFile , { relLink , title , status , date }) {
263 let content = '' ;
264 if (fs. existsSync (indexFile)) content = fs. readFileSync (indexFile, 'utf8' );
265 else content = '# ADR Log \n\n ' ;
266
267 if (content. includes (relLink)) return false ;
268
269 const normalized = content. replace ( / \r\n / g , ' \n ' );
270 const hadTrailingNewline = normalized. endsWith ( ' \n ' );
271 let lines = normalized. split ( ' \n ' );
272 // Normalize away the trailing empty split element so insertion math is sane.
273 if (
274 hadTrailingNewline &&
275 lines. length > 0 &&
276 lines[lines. length - 1 ] === ''
277 ) {
278 lines = lines. slice ( 0 , - 1 );
279 }
280 const entryLine = `- [${ title }](${ relLink }) (${ status }, ${ date })` ;
281
282 // Prefer inserting under "## ADRs" if it exists, otherwise append at EOF.
283 const r = insertIndexEntryUnderHeading (lines, / ^ ## \s + ADRs \s *$ / i , entryLine);
284 const nextLines = r.inserted ? r.lines : [ ... lines, entryLine];
285
286 let next = nextLines. join ( ' \n ' );
287 if (hadTrailingNewline) next += ' \n ' ;
288
289 fs. mkdirSync (path. dirname (indexFile), { recursive: true });
290 fs. writeFileSync (indexFile, next, 'utf8' );
291 return true ;
292 }
293
294 function main () {
295 const args = parseArgs (process.argv);
296
297 const repoRoot = path. resolve (process. cwd (), args.repoRoot);
298 if ( ! fs. existsSync (repoRoot)) die ( `Repo root does not exist: ${ repoRoot }` );
299
300 let adrDir;
301 if (args.dir) adrDir = path. resolve (repoRoot, args.dir);
302 else adrDir = detectAdrDir (repoRoot) || path. join (repoRoot, 'adr' );
303
304 if ( ! fs. existsSync (adrDir)) {
305 if (args.noCreateDir) die ( `ADR directory does not exist: ${ adrDir }` );
306 fs. mkdirSync (adrDir, { recursive: true });
307 }
308
309 let strategy = args.strategy;
310 if (strategy === 'auto' ) strategy = detectStrategy (adrDir);
311
312 const title = String (args.title). trim ();
313 const slug = slugify (title);
314
315 const today = todayISO ();
316
317 let filename;
318 if (strategy === 'date' ) {
319 filename = `${ today }-${ slug }.md` ;
320 } else {
321 filename = `${ slug }.md` ;
322 }
323
324 let out = path. join (adrDir, filename);
325 if (fs. existsSync (out)) {
326 if (strategy === 'date' ) die ( `ADR already exists: ${ out }` );
327 let i = 2 ;
328 while ( true ) {
329 const candidate = path. join (adrDir, `${ slug }-${ i }.md` );
330 if ( ! fs. existsSync (candidate)) {
331 out = candidate;
332 break ;
333 }
334 i ++ ;
335 }
336 }
337
338 const deciders = String (args.deciders || '' )
339 . split ( ',' )
340 . map ( s => s. trim ())
341 . filter (Boolean)
342 . join ( ', ' );
343
344 const consulted = String (args.consulted || '' )
345 . split ( ',' )
346 . map ( s => s. trim ())
347 . filter (Boolean)
348 . join ( ', ' );
349 const informed = String (args.informed || '' )
350 . split ( ',' )
351 . map ( s => s. trim ())
352 . filter (Boolean)
353 . join ( ', ' );
354
355 const raw = loadTemplate (args.template);
356 const rendered = renderTemplate (raw, {
357 title,
358 status: String (args.status). trim (),
359 date: today,
360 deciders,
361 consulted,
362 informed,
363 technicalStory: String (args.technicalStory || '' ). trim (),
364 chosenOption: String (args.chosenOption || '' ). trim (),
365 });
366
367 fs. writeFileSync (out, `${ rendered . trimEnd () } \n ` , 'utf8' );
368
369 let updatedIndexPath = null ;
370 let indexChanged = false ;
371
372 if (args.updateIndex) {
373 let indexFile;
374 if (args.indexFile) {
375 indexFile = path. isAbsolute (args.indexFile)
376 ? args.indexFile
377 : path. resolve (repoRoot, args.indexFile);
378 } else {
379 indexFile = chooseIndexFile (adrDir);
380 }
381
382 const relLink = toPosix (path. relative (path. dirname (indexFile), out));
383 indexChanged = updateIndex (indexFile, {
384 relLink,
385 title,
386 status: String (args.status). trim (),
387 date: today,
388 });
389 updatedIndexPath = indexFile;
390 }
391
392 if (args.json) {
393 const payload = {
394 repoRoot,
395 adrDir,
396 createdAdrPath: out,
397 createdAdrRelPath: toPosix (path. relative (repoRoot, out)),
398 title,
399 status: String (args.status). trim (),
400 template: args.template,
401 strategy,
402 date: today,
403 indexUpdated: Boolean (updatedIndexPath),
404 indexChanged,
405 indexPath: updatedIndexPath,
406 indexRelPath: updatedIndexPath
407 ? toPosix (path. relative (repoRoot, updatedIndexPath))
408 : null ,
409 };
410 process.stdout. write ( `${ JSON . stringify ( payload ) } \n ` );
411 } else {
412 process.stdout. write ( `${ out } \n ` );
413 }
414 }
415
416 main ();