@versatiles/release-tool 2.5.0 → 2.7.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.
@@ -1,7 +1,20 @@
1
1
  import { remark } from 'remark';
2
2
  import remarkGfm from 'remark-gfm';
3
3
  import remarkStringify from 'remark-stringify';
4
+ import { markdownError, notImplementedError } from '../lib/errors.js';
4
5
  import { getErrorMessage } from '../lib/utils.js';
6
+ // Custom blockquote handler that preserves GitHub alert syntax
7
+ function blockquoteHandler(node, _parent, state, info) {
8
+ const exit = state.enter('blockquote');
9
+ const tracker = state.createTracker(info);
10
+ tracker.move('> ');
11
+ tracker.shift(2);
12
+ let value = state.indentLines(state.containerFlow(node, tracker.current()), (line, _index, blank) => '>' + (blank ? '' : ' ') + line);
13
+ exit();
14
+ // Unescape GitHub alert markers that remark-stringify escapes
15
+ value = value.replace(/^(>\s*)\\\[!(NOTE|WARNING|TIP|IMPORTANT|CAUTION)\]/m, '$1[!$2]');
16
+ return value;
17
+ }
5
18
  /**
6
19
  * Injects a Markdown segment under a specified heading in a Markdown document.
7
20
  * Optionally, the injected segment can be made foldable for better readability.
@@ -24,7 +37,7 @@ export function injectMarkdown(document, segment, heading, foldable) {
24
37
  }
25
38
  catch (error) {
26
39
  // Handle errors during the search for the start index.
27
- throw new Error(`Error while searching for segment "${heading}": ${getErrorMessage(error)}`);
40
+ throw markdownError(`Error while searching for segment "${heading}": ${getErrorMessage(error)}`);
28
41
  }
29
42
  // Get the depth of the specified heading to maintain the structure.
30
43
  const depth = getHeadingDepth(documentAst, startIndex);
@@ -45,6 +58,9 @@ export function injectMarkdown(document, segment, heading, foldable) {
45
58
  .use(remarkStringify, {
46
59
  bullet: '-',
47
60
  rule: '-',
61
+ handlers: {
62
+ blockquote: blockquoteHandler,
63
+ },
48
64
  })
49
65
  .stringify(documentAst);
50
66
  return result;
@@ -62,7 +78,7 @@ export function updateTOC(main, heading) {
62
78
  const headingText = extractTextFromMDAsHTML(parseMarkdown(heading));
63
79
  // Build the TOC by iterating over each heading in the document.
64
80
  const toc = mainAst.children
65
- .flatMap(c => {
81
+ .flatMap((c) => {
66
82
  // Skip non-heading nodes and the specified heading.
67
83
  if (c.type !== 'heading' || extractTextFromMDAsHTML(c) === headingText)
68
84
  return [];
@@ -87,23 +103,23 @@ export function updateTOC(main, heading) {
87
103
  function findSegmentStartIndex(mainAst, headingAst) {
88
104
  // Verify the structure of the headingAst.
89
105
  if (headingAst.children.length !== 1)
90
- throw Error('headingAst.children.length !== 1');
106
+ throw markdownError('headingAst.children.length !== 1');
91
107
  if (headingAst.children[0].type !== 'heading')
92
- throw Error('headingAst.children[0].type !== \'heading\'');
108
+ throw markdownError("headingAst.children[0].type !== 'heading'");
93
109
  const sectionDepth = headingAst.children[0].depth;
94
110
  const sectionText = extractTextFromMDAsHTML(headingAst);
95
111
  // Search for the index of the heading in the main document AST.
96
112
  const indexes = mainAst.children.flatMap((c, index) => {
97
- if ((c.type === 'heading') && (c.depth === sectionDepth) && extractTextFromMDAsHTML(c).startsWith(sectionText)) {
113
+ if (c.type === 'heading' && c.depth === sectionDepth && extractTextFromMDAsHTML(c).startsWith(sectionText)) {
98
114
  return [index];
99
115
  }
100
116
  return [];
101
117
  });
102
118
  // Handle the cases of no match or multiple matches.
103
119
  if (indexes.length < 1)
104
- throw Error('section not found');
120
+ throw markdownError('section not found');
105
121
  if (indexes.length > 1)
106
- throw Error('too many sections found');
122
+ throw markdownError('too many sections found');
107
123
  return indexes[0];
108
124
  }
109
125
  /**
@@ -133,7 +149,7 @@ function findNextHeadingIndex(mainAst, startIndex, depth) {
133
149
  function getHeadingDepth(mainAst, index) {
134
150
  const node = mainAst.children[index];
135
151
  if (node.type !== 'heading')
136
- throw Error('node.type !== \'heading\'');
152
+ throw markdownError("node.type !== 'heading'");
137
153
  return node.depth;
138
154
  }
139
155
  /**
@@ -142,9 +158,9 @@ function getHeadingDepth(mainAst, index) {
142
158
  * @param depth The depth to which the segment should be indented.
143
159
  */
144
160
  function indentSegmentToDepth(segmentAst, depth) {
145
- segmentAst.children.forEach(node => {
161
+ segmentAst.children.forEach((node) => {
146
162
  if (node.type == 'heading')
147
- return node.depth += depth;
163
+ return (node.depth += depth);
148
164
  });
149
165
  }
150
166
  /**
@@ -159,53 +175,113 @@ function mergeSegments(mainAst, segmentAst, startIndex, endIndex) {
159
175
  }
160
176
  /**
161
177
  * Extracts the textual content from a node in the AST.
162
- * @param node The AST node.
163
- * @returns The extracted text content.
164
- * @throws Error if the node type is unknown.
178
+ * Recursively processes nodes with children and extracts text values.
179
+ *
180
+ * @param node The AST node (Root or any RootContent type).
181
+ * @returns The extracted text content as HTML-escaped string.
165
182
  */
166
183
  function extractTextFromMDAsHTML(node) {
167
184
  switch (node.type) {
185
+ // Nodes with direct text value
168
186
  case 'inlineCode':
169
187
  case 'text':
170
188
  return textToHtml(node.value);
189
+ // Nodes with children to recurse into
171
190
  case 'heading':
172
191
  case 'root':
173
- return node.children.map(extractTextFromMDAsHTML).join('');
192
+ case 'paragraph':
193
+ case 'blockquote':
194
+ case 'listItem':
195
+ case 'tableCell':
196
+ case 'tableRow':
197
+ case 'link':
198
+ case 'emphasis':
199
+ case 'strong':
200
+ case 'delete':
201
+ return node.children.map((child) => extractTextFromMDAsHTML(child)).join('');
202
+ // Nodes with children but need special handling
203
+ case 'list':
204
+ return node.children.map((child) => extractTextFromMDAsHTML(child)).join('');
205
+ case 'table':
206
+ return node.children.map((child) => extractTextFromMDAsHTML(child)).join('');
207
+ // Nodes with alt text
208
+ case 'image':
209
+ return node.alt ? textToHtml(node.alt) : '';
210
+ // Nodes that don't contribute text content
174
211
  case 'html':
212
+ case 'code':
213
+ case 'thematicBreak':
214
+ case 'break':
215
+ case 'definition':
216
+ case 'footnoteDefinition':
217
+ case 'footnoteReference':
218
+ case 'imageReference':
219
+ case 'linkReference':
220
+ case 'yaml':
175
221
  return '';
176
- default:
177
- console.log(node);
178
- throw Error('unknown type: ' + node.type);
222
+ default: {
223
+ // TypeScript exhaustive check - this ensures we handle all types
224
+ const _exhaustiveCheck = node;
225
+ throw markdownError(`unhandled node type: ${node.type}`);
226
+ }
179
227
  }
180
228
  }
181
229
  /**
182
230
  * Generates an anchor ID for a Markdown heading based on its text content.
231
+ * Handles all PhrasingContent types that can appear in a heading.
232
+ *
183
233
  * @param node The heading node.
184
- * @returns The generated anchor ID.
185
- * @throws Error if the child node type is unknown.
234
+ * @returns The generated anchor ID, formatted for use in URLs.
186
235
  */
187
236
  function getMDAnchor(node) {
188
237
  let text = '';
189
238
  for (const c of node.children) {
190
239
  // Handle different types of child nodes to construct the anchor text.
191
240
  switch (c.type) {
241
+ // Check for explicit ID in HTML
192
242
  case 'html': {
193
243
  const match = /<a\s.*id\s*=\s*['"]([^'"]+)/i.exec(c.value);
194
244
  if (match)
195
245
  return match[1];
196
246
  break;
197
247
  }
248
+ // Nodes with direct text value
198
249
  case 'text':
199
250
  case 'inlineCode':
200
251
  text += c.value;
201
252
  break;
202
- default:
203
- console.log(c);
204
- throw Error('unknown type: ' + c.type);
253
+ // Nodes with children - recurse to extract text
254
+ case 'emphasis':
255
+ case 'strong':
256
+ case 'delete':
257
+ case 'link':
258
+ for (const child of c.children) {
259
+ if (child.type === 'text' || child.type === 'inlineCode') {
260
+ text += child.value;
261
+ }
262
+ }
263
+ break;
264
+ // Image - use alt text
265
+ case 'image':
266
+ if (c.alt)
267
+ text += c.alt;
268
+ break;
269
+ // Nodes that don't contribute to anchor text
270
+ case 'break':
271
+ case 'footnoteReference':
272
+ case 'imageReference':
273
+ case 'linkReference':
274
+ break;
275
+ default: {
276
+ // TypeScript exhaustive check
277
+ const _exhaustiveCheck = c;
278
+ throw markdownError(`unhandled phrasing content type: ${c.type}`);
279
+ }
205
280
  }
206
281
  }
207
282
  // Format the text to create a suitable anchor ID.
208
- text = text.toLowerCase()
283
+ text = text
284
+ .toLowerCase()
209
285
  .replace(/[()]+/g, '')
210
286
  .replace(/[^a-z0-9]+/g, '-')
211
287
  .replace(/^-+|-+$/g, '');
@@ -213,6 +289,8 @@ function getMDAnchor(node) {
213
289
  }
214
290
  /**
215
291
  * Converts a segment of the AST into a foldable HTML element.
292
+ * Headings become collapsible `<details>` sections.
293
+ *
216
294
  * @param ast The AST of the segment to be converted.
217
295
  */
218
296
  function convertToFoldable(ast) {
@@ -220,25 +298,62 @@ function convertToFoldable(ast) {
220
298
  const children = [];
221
299
  ast.children.forEach((c) => {
222
300
  switch (c.type) {
223
- case 'html':
224
- case 'list':
225
- case 'paragraph':
226
- children.push(c);
227
- break;
301
+ // Headings start new foldable sections
228
302
  case 'heading':
229
303
  closeDetails(c.depth);
230
304
  children.push({ type: 'html', value: '<details>' });
231
305
  children.push({ type: 'html', value: `<summary>${lineToHtml(c)}</summary>` });
232
306
  openDetails.unshift(c.depth);
233
307
  break;
234
- default:
235
- throw Error(`unknown type "${c.type}"`);
308
+ // Block content that gets included in sections
309
+ case 'html':
310
+ case 'list':
311
+ case 'paragraph':
312
+ case 'blockquote':
313
+ case 'code':
314
+ case 'table':
315
+ case 'thematicBreak':
316
+ children.push(c);
317
+ break;
318
+ // Definition content - include as-is
319
+ case 'definition':
320
+ case 'footnoteDefinition':
321
+ children.push(c);
322
+ break;
323
+ // YAML frontmatter - include as-is
324
+ case 'yaml':
325
+ children.push(c);
326
+ break;
327
+ // Inline/phrasing content that shouldn't appear at root level
328
+ // but handle gracefully if present
329
+ case 'text':
330
+ case 'inlineCode':
331
+ case 'emphasis':
332
+ case 'strong':
333
+ case 'delete':
334
+ case 'link':
335
+ case 'image':
336
+ case 'break':
337
+ case 'footnoteReference':
338
+ case 'imageReference':
339
+ case 'linkReference':
340
+ case 'listItem':
341
+ case 'tableCell':
342
+ case 'tableRow':
343
+ // These shouldn't normally appear at root level, but pass through
344
+ children.push(c);
345
+ break;
346
+ default: {
347
+ // TypeScript exhaustive check
348
+ const _exhaustiveCheck = c;
349
+ throw markdownError(`unhandled root content type: ${c.type}`);
350
+ }
236
351
  }
237
352
  });
238
353
  closeDetails(0);
239
354
  ast.children = children;
240
355
  function closeDetails(depth) {
241
- while ((openDetails.length > 0) && (openDetails[0] >= depth)) {
356
+ while (openDetails.length > 0 && openDetails[0] >= depth) {
242
357
  children.push({ type: 'html', value: '</details>' });
243
358
  openDetails.shift();
244
359
  }
@@ -247,6 +362,14 @@ function convertToFoldable(ast) {
247
362
  function lineToHtml(heading) {
248
363
  return `<h${heading.depth}>${nodesToHtml(heading.children)}</h${heading.depth}>`;
249
364
  }
365
+ /**
366
+ * Converts a PhrasingContent node to its HTML representation.
367
+ * Handles all PhrasingContent types defined in mdast.
368
+ *
369
+ * @param node The phrasing content node to convert.
370
+ * @returns The HTML string representation.
371
+ * @throws {VrtError} For reference types that require resolution (footnote, image, link references).
372
+ */
250
373
  export function nodeToHtml(node) {
251
374
  switch (node.type) {
252
375
  case 'html':
@@ -273,19 +396,25 @@ export function nodeToHtml(node) {
273
396
  attributes.push(`title="${node.title}"`);
274
397
  return `<img ${attributes.join(' ')} />`;
275
398
  }
276
- case 'footnoteReference': throw new Error('Not implemented yet: "footnoteReference" case');
277
- case 'imageReference': throw new Error('Not implemented yet: "imageReference" case');
278
- case 'linkReference': throw new Error('Not implemented yet: "linkReference" case');
279
- default:
280
- console.log(node);
281
- throw Error('unknown type');
399
+ // Reference types require definition resolution which is not supported
400
+ case 'footnoteReference':
401
+ throw notImplementedError('footnoteReference - requires definition resolution');
402
+ case 'imageReference':
403
+ throw notImplementedError('imageReference - requires definition resolution');
404
+ case 'linkReference':
405
+ throw notImplementedError('linkReference - requires definition resolution');
406
+ default: {
407
+ // TypeScript exhaustive check
408
+ const _exhaustiveCheck = node;
409
+ throw markdownError(`unhandled phrasing content type: ${node.type}`);
410
+ }
282
411
  }
283
412
  }
284
413
  function nodesToHtml(children) {
285
414
  return children.map(nodeToHtml).join('');
286
415
  }
287
416
  function textToHtml(text) {
288
- return text.replace(/[^a-z0-9 ,.\-:_?@äöüß]/gi, c => `&#${c.charCodeAt(0)};`);
417
+ return text.replace(/[^a-z0-9 ,.\-:_?@äöüß]/gi, (c) => `&#${c.charCodeAt(0)};`);
289
418
  }
290
419
  export function parseMarkdown(document) {
291
420
  return remark().use(remarkGfm).parse(document);
@@ -1,2 +1,45 @@
1
1
  #!/usr/bin/env npx tsx
2
- export declare function release(directory: string, branch?: string): Promise<void>;
2
+ /**
3
+ * Options for the release process.
4
+ */
5
+ export interface ReleaseOptions {
6
+ /** The project directory containing package.json (default: current directory) */
7
+ directory?: string;
8
+ /** The git branch to release from (default: 'main') */
9
+ branch?: string;
10
+ /** If true, simulate the release without making changes (default: false) */
11
+ dryRun?: boolean;
12
+ }
13
+ /**
14
+ * Executes the npm release process.
15
+ *
16
+ * This function performs a complete release workflow:
17
+ * 1. Validates git state (correct branch, no uncommitted changes)
18
+ * 2. Pulls latest changes from remote
19
+ * 3. Verifies npm authentication
20
+ * 4. Prompts for new version (with suggestion based on conventional commits)
21
+ * 5. Runs project checks
22
+ * 6. Updates package.json version
23
+ * 7. Updates CHANGELOG.md
24
+ * 8. Publishes to npm (if not private)
25
+ * 9. Creates git commit and tag
26
+ * 10. Pushes to remote and creates GitHub release
27
+ *
28
+ * @param directory - The project directory containing package.json
29
+ * @param branch - The git branch to release from (default: 'main')
30
+ * @param dryRun - If true, simulate the release without making changes
31
+ * @throws {VrtError} If any step in the release process fails
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * // Standard release from main branch
36
+ * await release('/path/to/project');
37
+ *
38
+ * // Dry run to preview release
39
+ * await release('/path/to/project', 'main', true);
40
+ *
41
+ * // Release from a different branch
42
+ * await release('/path/to/project', 'release');
43
+ * ```
44
+ */
45
+ export declare function release(directory: string, branch?: string, dryRun?: boolean): Promise<void>;