@link-assistant/web-capture 1.1.2

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.
Files changed (56) hide show
  1. package/.changeset/README.md +8 -0
  2. package/.changeset/config.json +11 -0
  3. package/.github/workflows/release.yml +294 -0
  4. package/.jscpd.json +20 -0
  5. package/.lenv +8 -0
  6. package/.prettierignore +7 -0
  7. package/.prettierrc +10 -0
  8. package/ARCHITECTURE.md +982 -0
  9. package/CHANGELOG.md +22 -0
  10. package/Dockerfile +45 -0
  11. package/LICENSE +24 -0
  12. package/README.md +268 -0
  13. package/apply.sh +166 -0
  14. package/babel.config.cjs +13 -0
  15. package/bin/web-capture.js +285 -0
  16. package/docker-compose.yml +9 -0
  17. package/docs/market-analysis-2025-05-13.md +64 -0
  18. package/eslint.config.js +112 -0
  19. package/examples/js/engine_comparison.js +39 -0
  20. package/examples/js/html_download.js +23 -0
  21. package/examples/js/image_download.js +36 -0
  22. package/examples/js/markdown_download.js +23 -0
  23. package/examples/js/playwright_example.js +23 -0
  24. package/examples/python/html_download.py +15 -0
  25. package/examples/python/image_download.py +20 -0
  26. package/examples/python/markdown_download.py +15 -0
  27. package/examples/python/playwright_example.py +22 -0
  28. package/jest.config.mjs +18 -0
  29. package/package.json +83 -0
  30. package/scripts/changeset-version.mjs +38 -0
  31. package/scripts/create-github-release.mjs +93 -0
  32. package/scripts/create-manual-changeset.mjs +88 -0
  33. package/scripts/format-github-release.mjs +83 -0
  34. package/scripts/format-release-notes.mjs +218 -0
  35. package/scripts/instant-version-bump.mjs +121 -0
  36. package/scripts/publish-to-npm.mjs +128 -0
  37. package/scripts/setup-npm.mjs +37 -0
  38. package/scripts/validate-changeset.mjs +106 -0
  39. package/scripts/version-and-commit.mjs +237 -0
  40. package/src/browser.js +207 -0
  41. package/src/fetch.js +38 -0
  42. package/src/html.js +64 -0
  43. package/src/image.js +41 -0
  44. package/src/index.js +55 -0
  45. package/src/lib.js +330 -0
  46. package/src/markdown.js +17 -0
  47. package/src/stream.js +50 -0
  48. package/tests/e2e/docker.test.js +148 -0
  49. package/tests/e2e/process.test.js +117 -0
  50. package/tests/integration/browser-engines.test.js +128 -0
  51. package/tests/jest.setup.mjs +7 -0
  52. package/tests/mock/index.test.js +223 -0
  53. package/tests/unit/browser.test.js +136 -0
  54. package/tests/unit/cli.test.js +151 -0
  55. package/tests/unit/convertRelativeUrls.test.js +34 -0
  56. package/tests/unit/html2md.test.js +610 -0
@@ -0,0 +1,22 @@
1
+ import requests
2
+ import os
3
+
4
+ base_url = 'http://localhost:3000'
5
+ target_url = 'https://example.com'
6
+
7
+ # Create output directory if it doesn't exist
8
+ os.makedirs('output', exist_ok=True)
9
+
10
+ print('Fetching HTML using Playwright engine...')
11
+ html_response = requests.get(f'{base_url}/html', params={'url': target_url, 'engine': 'playwright'})
12
+ with open('output/playwright_html.html', 'w') as f:
13
+ f.write(html_response.text)
14
+ print('HTML saved to output/playwright_html.html')
15
+
16
+ print('Fetching screenshot using Playwright engine...')
17
+ image_response = requests.get(f'{base_url}/image', params={'url': target_url, 'engine': 'playwright'})
18
+ with open('output/playwright_screenshot.png', 'wb') as f:
19
+ f.write(image_response.content)
20
+ print('Screenshot saved to output/playwright_screenshot.png')
21
+
22
+ print('Done! Both requests used Playwright engine.')
@@ -0,0 +1,18 @@
1
+ export default {
2
+ transform: {
3
+ '^.+\\.js$': ['babel-jest', { configFile: './babel.config.cjs' }],
4
+ },
5
+ transformIgnorePatterns: ['node_modules/(?!(turndown)/)'],
6
+ moduleNameMapper: {
7
+ '^(\\.{1,2}/.*)\\.js$': '$1',
8
+ },
9
+ testEnvironment: 'node',
10
+ verbose: true,
11
+ testMatch: [
12
+ '**/tests/unit/**/*.test.js',
13
+ '**/tests/mock/**/*.test.js',
14
+ '**/tests/e2e/**/*.test.js',
15
+ '**/tests/integration/**/*.test.js',
16
+ ],
17
+ setupFiles: ['./tests/jest.setup.mjs'],
18
+ };
package/package.json ADDED
@@ -0,0 +1,83 @@
1
+ {
2
+ "name": "@link-assistant/web-capture",
3
+ "version": "1.1.2",
4
+ "description": "CLI and microservice to render web pages as HTML, Markdown, or PNG",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "web-capture": "./bin/web-capture.js"
8
+ },
9
+ "scripts": {
10
+ "start": "docker compose up -d",
11
+ "dev": "nodemon bin/web-capture.js -- --serve",
12
+ "test": "node --experimental-vm-modules ./node_modules/.bin/jest",
13
+ "test:watch": "node --experimental-vm-modules ./node_modules/.bin/jest --watch",
14
+ "test:e2e:process": "node --experimental-vm-modules ./node_modules/.bin/jest tests/e2e.process.test.js",
15
+ "test:e2e:docker": "node --experimental-vm-modules ./node_modules/.bin/jest tests/e2e.docker.test.js",
16
+ "test:e2e": "yarn test:e2e:process && yarn test:e2e:docker",
17
+ "test:all": "yarn build && yarn test && yarn test:e2e",
18
+ "build": "docker compose down && docker compose build && yarn start",
19
+ "examples:python": "python3 examples/python/html_download.py && python3 examples/python/markdown_download.py && python3 examples/python/image_download.py",
20
+ "examples:javascript": "node examples/js/html_download.js && node examples/js/markdown_download.js && node examples/js/image_download.js",
21
+ "examples": "yarn build && yarn examples:javascript && yarn examples:python",
22
+ "lint": "eslint .",
23
+ "lint:fix": "eslint . --fix",
24
+ "format": "prettier --write .",
25
+ "format:check": "prettier --check .",
26
+ "check:duplication": "jscpd .",
27
+ "check": "npm run lint && npm run format:check && npm run check:duplication",
28
+ "prepare": "husky || true",
29
+ "changeset": "changeset",
30
+ "changeset:version": "node scripts/changeset-version.mjs",
31
+ "changeset:publish": "changeset publish",
32
+ "changeset:status": "changeset status --since=origin/main"
33
+ },
34
+ "dependencies": {
35
+ "cheerio": "^1.0.0",
36
+ "express": "^4.18.2",
37
+ "lino-arguments": "^0.2.1",
38
+ "node-fetch": "^2.7.0",
39
+ "playwright": "^1.49.0",
40
+ "puppeteer": "^24.8.2",
41
+ "turndown": "^7.1.1"
42
+ },
43
+ "devDependencies": {
44
+ "@babel/core": "^7.24.0",
45
+ "@babel/preset-env": "^7.24.0",
46
+ "@changesets/cli": "^2.29.7",
47
+ "babel-jest": "^29.7.0",
48
+ "eslint": "^9.38.0",
49
+ "eslint-config-prettier": "^10.1.8",
50
+ "eslint-plugin-prettier": "^5.5.4",
51
+ "get-port": "^7.1.0",
52
+ "husky": "^9.1.7",
53
+ "jest": "^29.7.0",
54
+ "jscpd": "^4.0.5",
55
+ "lint-staged": "^16.2.6",
56
+ "nock": "^13.5.4",
57
+ "prettier": "^3.6.2",
58
+ "supertest": "^6.3.4",
59
+ "turndown-plugin-gfm": "^1.0.2"
60
+ },
61
+ "type": "module",
62
+ "keywords": [
63
+ "render",
64
+ "markdown",
65
+ "screenshot",
66
+ "microservice"
67
+ ],
68
+ "license": "UNLICENSED",
69
+ "engines": {
70
+ "node": ">=22.0.0 <23.0.0"
71
+ },
72
+ "lint-staged": {
73
+ "*.{js,mjs,cjs}": [
74
+ "eslint --fix --max-warnings 0",
75
+ "prettier --write",
76
+ "prettier --check"
77
+ ],
78
+ "*.md": [
79
+ "prettier --write",
80
+ "prettier --check"
81
+ ]
82
+ }
83
+ }
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Custom changeset version script that ensures package-lock.json is synchronized
5
+ * with package.json after version bumps.
6
+ *
7
+ * This script:
8
+ * 1. Runs `changeset version` to update package versions
9
+ * 2. Runs `npm install` to synchronize package-lock.json with the new versions
10
+ *
11
+ * Uses link-foundation libraries:
12
+ * - use-m: Dynamic package loading without package.json dependencies
13
+ * - command-stream: Modern shell command execution with streaming support
14
+ */
15
+
16
+ // Load use-m dynamically
17
+ const { use } = eval(
18
+ await (await fetch('https://unpkg.com/use-m/use.js')).text()
19
+ );
20
+
21
+ // Import command-stream for shell command execution
22
+ const { $ } = await use('command-stream');
23
+
24
+ try {
25
+ console.log('Running changeset version...');
26
+ await $`npx changeset version`;
27
+
28
+ console.log('\nSynchronizing package-lock.json...');
29
+ await $`npm install --package-lock-only`;
30
+
31
+ console.log('\n✅ Version bump complete with synchronized package-lock.json');
32
+ } catch (error) {
33
+ console.error('Error during version bump:', error.message);
34
+ if (process.env.DEBUG) {
35
+ console.error('Stack trace:', error.stack);
36
+ }
37
+ process.exit(1);
38
+ }
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Create GitHub Release from CHANGELOG.md
5
+ * Usage: node scripts/create-github-release.mjs --release-version <version> --repository <repository>
6
+ * release-version: Version number (e.g., 1.0.0)
7
+ * repository: GitHub repository (e.g., owner/repo)
8
+ *
9
+ * Uses link-foundation libraries:
10
+ * - use-m: Dynamic package loading without package.json dependencies
11
+ * - command-stream: Modern shell command execution with streaming support
12
+ * - lino-arguments: Unified configuration from CLI args, env vars, and .lenv files
13
+ */
14
+
15
+ import { readFileSync } from 'fs';
16
+
17
+ // Load use-m dynamically
18
+ const { use } = eval(
19
+ await (await fetch('https://unpkg.com/use-m/use.js')).text()
20
+ );
21
+
22
+ // Import link-foundation libraries
23
+ const { $ } = await use('command-stream');
24
+ const { makeConfig } = await use('lino-arguments');
25
+
26
+ // Parse CLI arguments using lino-arguments
27
+ // Note: Using --release-version instead of --version to avoid conflict with yargs' built-in --version flag
28
+ const config = makeConfig({
29
+ yargs: ({ yargs, getenv }) =>
30
+ yargs
31
+ .option('release-version', {
32
+ type: 'string',
33
+ default: getenv('VERSION', ''),
34
+ describe: 'Version number (e.g., 1.0.0)',
35
+ })
36
+ .option('repository', {
37
+ type: 'string',
38
+ default: getenv('REPOSITORY', ''),
39
+ describe: 'GitHub repository (e.g., owner/repo)',
40
+ }),
41
+ });
42
+
43
+ const { releaseVersion: version, repository } = config;
44
+
45
+ if (!version || !repository) {
46
+ console.error('Error: Missing required arguments');
47
+ console.error(
48
+ 'Usage: node scripts/create-github-release.mjs --release-version <version> --repository <repository>'
49
+ );
50
+ process.exit(1);
51
+ }
52
+
53
+ const tag = `v${version}`;
54
+
55
+ console.log(`Creating GitHub release for ${tag}...`);
56
+
57
+ try {
58
+ // Read CHANGELOG.md
59
+ const changelog = readFileSync('./CHANGELOG.md', 'utf8');
60
+
61
+ // Extract changelog entry for this version
62
+ // Read from CHANGELOG.md between this version header and the next version header
63
+ const versionHeaderRegex = new RegExp(`## ${version}[\\s\\S]*?(?=## \\d|$)`);
64
+ const match = changelog.match(versionHeaderRegex);
65
+
66
+ let releaseNotes = '';
67
+ if (match) {
68
+ // Remove the version header itself and trim
69
+ releaseNotes = match[0].replace(`## ${version}`, '').trim();
70
+ }
71
+
72
+ if (!releaseNotes) {
73
+ releaseNotes = `Release ${version}`;
74
+ }
75
+
76
+ // Create release using GitHub API with JSON input
77
+ // This avoids shell escaping issues that occur when passing text via command-line arguments
78
+ // (Previously caused apostrophes like "didn't" to appear as "didn'''" in releases)
79
+ const payload = JSON.stringify({
80
+ tag_name: tag,
81
+ name: version,
82
+ body: releaseNotes,
83
+ });
84
+
85
+ await $`gh api repos/${repository}/releases -X POST --input -`.run({
86
+ stdin: payload,
87
+ });
88
+
89
+ console.log(`\u2705 Created GitHub release: ${tag}`);
90
+ } catch (error) {
91
+ console.error('Error creating release:', error.message);
92
+ process.exit(1);
93
+ }
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Create a changeset file for manual releases
5
+ * Usage: node scripts/create-manual-changeset.mjs --bump-type <major|minor|patch> [--description <description>]
6
+ *
7
+ * IMPORTANT: Update the PACKAGE_NAME constant below to match your package.json
8
+ *
9
+ * Uses link-foundation libraries:
10
+ * - use-m: Dynamic package loading without package.json dependencies
11
+ * - command-stream: Modern shell command execution with streaming support
12
+ * - lino-arguments: Unified configuration from CLI args, env vars, and .lenv files
13
+ */
14
+
15
+ import { writeFileSync } from 'fs';
16
+ import { randomBytes } from 'crypto';
17
+
18
+ const PACKAGE_NAME = '@link-assistant/web-capture';
19
+
20
+ // Load use-m dynamically
21
+ const { use } = eval(
22
+ await (await fetch('https://unpkg.com/use-m/use.js')).text()
23
+ );
24
+
25
+ // Import link-foundation libraries
26
+ const { $ } = await use('command-stream');
27
+ const { makeConfig } = await use('lino-arguments');
28
+
29
+ // Parse CLI arguments using lino-arguments
30
+ const config = makeConfig({
31
+ yargs: ({ yargs, getenv }) =>
32
+ yargs
33
+ .option('bump-type', {
34
+ type: 'string',
35
+ default: getenv('BUMP_TYPE', ''),
36
+ describe: 'Version bump type: major, minor, or patch',
37
+ choices: ['major', 'minor', 'patch'],
38
+ })
39
+ .option('description', {
40
+ type: 'string',
41
+ default: getenv('DESCRIPTION', ''),
42
+ describe: 'Description for the changeset',
43
+ }),
44
+ });
45
+
46
+ try {
47
+ const { bumpType, description: descriptionArg } = config;
48
+
49
+ // Use provided description or default based on bump type
50
+ const description = descriptionArg || `Manual ${bumpType} release`;
51
+
52
+ if (!bumpType || !['major', 'minor', 'patch'].includes(bumpType)) {
53
+ console.error(
54
+ 'Usage: node scripts/create-manual-changeset.mjs --bump-type <major|minor|patch> [--description <description>]'
55
+ );
56
+ process.exit(1);
57
+ }
58
+
59
+ // Generate a random changeset ID
60
+ const changesetId = randomBytes(4).toString('hex');
61
+ const changesetFile = `.changeset/manual-release-${changesetId}.md`;
62
+
63
+ // Create the changeset file with single quotes to match Prettier config
64
+ const content = `---
65
+ '${PACKAGE_NAME}': ${bumpType}
66
+ ---
67
+
68
+ ${description}
69
+ `;
70
+
71
+ writeFileSync(changesetFile, content, 'utf-8');
72
+
73
+ console.log(`Created changeset: ${changesetFile}`);
74
+ console.log('Content:');
75
+ console.log(content);
76
+
77
+ // Format with Prettier
78
+ console.log('\nFormatting with Prettier...');
79
+ await $`npx prettier --write "${changesetFile}"`;
80
+
81
+ console.log('\n✅ Changeset created and formatted successfully');
82
+ } catch (error) {
83
+ console.error('Error creating changeset:', error.message);
84
+ if (process.env.DEBUG) {
85
+ console.error('Stack trace:', error.stack);
86
+ }
87
+ process.exit(1);
88
+ }
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Format GitHub release notes using the format-release-notes.mjs script
5
+ * Usage: node scripts/format-github-release.mjs --release-version <version> --repository <repository> --commit-sha <commit_sha>
6
+ * release-version: Version number (e.g., 1.0.0)
7
+ * repository: GitHub repository (e.g., owner/repo)
8
+ * commit_sha: Commit SHA for PR detection
9
+ *
10
+ * Uses link-foundation libraries:
11
+ * - use-m: Dynamic package loading without package.json dependencies
12
+ * - command-stream: Modern shell command execution with streaming support
13
+ * - lino-arguments: Unified configuration from CLI args, env vars, and .lenv files
14
+ */
15
+
16
+ // Load use-m dynamically
17
+ const { use } = eval(
18
+ await (await fetch('https://unpkg.com/use-m/use.js')).text()
19
+ );
20
+
21
+ // Import link-foundation libraries
22
+ const { $ } = await use('command-stream');
23
+ const { makeConfig } = await use('lino-arguments');
24
+
25
+ // Parse CLI arguments using lino-arguments
26
+ // Note: Using --release-version instead of --version to avoid conflict with yargs' built-in --version flag
27
+ const config = makeConfig({
28
+ yargs: ({ yargs, getenv }) =>
29
+ yargs
30
+ .option('release-version', {
31
+ type: 'string',
32
+ default: getenv('VERSION', ''),
33
+ describe: 'Version number (e.g., 1.0.0)',
34
+ })
35
+ .option('repository', {
36
+ type: 'string',
37
+ default: getenv('REPOSITORY', ''),
38
+ describe: 'GitHub repository (e.g., owner/repo)',
39
+ })
40
+ .option('commit-sha', {
41
+ type: 'string',
42
+ default: getenv('COMMIT_SHA', ''),
43
+ describe: 'Commit SHA for PR detection',
44
+ }),
45
+ });
46
+
47
+ const { releaseVersion: version, repository, commitSha } = config;
48
+
49
+ if (!version || !repository || !commitSha) {
50
+ console.error('Error: Missing required arguments');
51
+ console.error(
52
+ 'Usage: node scripts/format-github-release.mjs --release-version <version> --repository <repository> --commit-sha <commit_sha>'
53
+ );
54
+ process.exit(1);
55
+ }
56
+
57
+ const tag = `v${version}`;
58
+
59
+ try {
60
+ // Get the release ID for this version
61
+ let releaseId = '';
62
+ try {
63
+ const result =
64
+ await $`gh api "repos/${repository}/releases/tags/${tag}" --jq '.id'`.run(
65
+ { capture: true }
66
+ );
67
+ releaseId = result.stdout.trim();
68
+ } catch {
69
+ console.log(`\u26A0\uFE0F Could not find release for ${tag}`);
70
+ process.exit(0);
71
+ }
72
+
73
+ if (releaseId) {
74
+ console.log(`Formatting release notes for ${tag}...`);
75
+ // Pass the trigger commit SHA for PR detection
76
+ // This allows proper PR lookup even if the changelog doesn't have a commit hash
77
+ await $`node scripts/format-release-notes.mjs --release-id "${releaseId}" --release-version "${tag}" --repository "${repository}" --commit-sha "${commitSha}"`;
78
+ console.log(`\u2705 Formatted release notes for ${tag}`);
79
+ }
80
+ } catch (error) {
81
+ console.error('Error formatting release:', error.message);
82
+ process.exit(1);
83
+ }
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Script to format GitHub release notes with proper formatting:
5
+ * - Fix special characters like \n
6
+ * - Add link to PR that contains the release commit (if found)
7
+ * - Add shields.io NPM version badge
8
+ * - Format nicely with proper markdown
9
+ *
10
+ * IMPORTANT: Update the PACKAGE_NAME constant below to match your package.json
11
+ *
12
+ * PR Detection Logic:
13
+ * 1. Extract commit hash from changelog entry (if present)
14
+ * 2. Fall back to --commit-sha argument (passed from workflow)
15
+ * 3. Look up PRs that contain the commit via GitHub API
16
+ * 4. If no PR found, simply don't display any PR link (no guessing)
17
+ *
18
+ * Uses link-foundation libraries:
19
+ * - use-m: Dynamic package loading without package.json dependencies
20
+ * - command-stream: Modern shell command execution with streaming support
21
+ * - lino-arguments: Unified configuration from CLI args, env vars, and .lenv files
22
+ *
23
+ * Note: Uses --release-version instead of --version to avoid conflict with yargs' built-in --version flag.
24
+ */
25
+
26
+ const PACKAGE_NAME = '@link-assistant/web-capture';
27
+
28
+ // Load use-m dynamically
29
+ const { use } = eval(
30
+ await (await fetch('https://unpkg.com/use-m/use.js')).text()
31
+ );
32
+
33
+ // Import link-foundation libraries
34
+ const { $ } = await use('command-stream');
35
+ const { makeConfig } = await use('lino-arguments');
36
+
37
+ // Parse CLI arguments using lino-arguments
38
+ // Note: Using --release-version instead of --version to avoid conflict with yargs' built-in --version flag
39
+ const config = makeConfig({
40
+ yargs: ({ yargs, getenv }) =>
41
+ yargs
42
+ .option('release-version', {
43
+ type: 'string',
44
+ default: getenv('VERSION', ''),
45
+ describe: 'Version number (e.g., v0.8.36)',
46
+ })
47
+ .option('release-id', {
48
+ type: 'string',
49
+ default: getenv('RELEASE_ID', ''),
50
+ describe: 'GitHub release ID',
51
+ })
52
+ .option('repository', {
53
+ type: 'string',
54
+ default: getenv('REPOSITORY', ''),
55
+ describe: 'GitHub repository (e.g., owner/repo)',
56
+ })
57
+ .option('commit-sha', {
58
+ type: 'string',
59
+ default: getenv('COMMIT_SHA', ''),
60
+ describe: 'Commit SHA for PR detection',
61
+ }),
62
+ });
63
+
64
+ const releaseId = config.releaseId;
65
+ const version = config.releaseVersion;
66
+ const repository = config.repository;
67
+ const passedCommitSha = config.commitSha;
68
+
69
+ if (!releaseId || !version || !repository) {
70
+ console.error(
71
+ 'Usage: format-release-notes.mjs --release-id <releaseId> --release-version <version> --repository <repository> [--commit-sha <sha>]'
72
+ );
73
+ process.exit(1);
74
+ }
75
+
76
+ try {
77
+ // Get current release body
78
+ const result = await $`gh api repos/${repository}/releases/${releaseId}`.run({
79
+ capture: true,
80
+ });
81
+ const releaseData = JSON.parse(result.stdout);
82
+
83
+ const currentBody = releaseData.body || '';
84
+
85
+ // Skip if already formatted (has shields.io badge image)
86
+ if (currentBody.includes('img.shields.io')) {
87
+ console.log('ℹ️ Release notes already formatted');
88
+ process.exit(0);
89
+ }
90
+
91
+ // Extract changes section (Major, Minor, or Patch)
92
+ // This regex handles multiple formats:
93
+ // 1. With commit hash: "### [Major|Minor|Patch] Changes\n- abc1234: Description"
94
+ // 2. Without commit hash: "### [Major|Minor|Patch] Changes\n- Description"
95
+ const changesPattern =
96
+ /### (Major|Minor|Patch) Changes\s*\n\s*-\s+(?:([a-f0-9]+):\s+)?(.+?)$/s;
97
+ const changesMatch = currentBody.match(changesPattern);
98
+
99
+ let commitHash = null;
100
+ let rawDescription = null;
101
+ let changeType = null;
102
+
103
+ if (changesMatch) {
104
+ // Extract: [full match, changeType, commitHash (optional), description]
105
+ [, changeType, commitHash, rawDescription] = changesMatch;
106
+ console.log(`ℹ️ Found ${changeType} Changes section`);
107
+
108
+ // If commitHash is undefined and description contains it, try to extract
109
+ if (!commitHash && rawDescription) {
110
+ // This handles the case where description itself might be null/undefined
111
+ // and we need to safely check for commit hash at the start
112
+ const descWithHashMatch = rawDescription.match(/^([a-f0-9]+):\s+(.+)$/s);
113
+ if (descWithHashMatch) {
114
+ [, commitHash, rawDescription] = descWithHashMatch;
115
+ }
116
+ }
117
+ } else {
118
+ console.log('⚠️ Could not parse changes from release notes');
119
+ console.log(' Looking for pattern: ### [Major|Minor|Patch] Changes');
120
+ process.exit(0);
121
+ }
122
+
123
+ // Clean up the description:
124
+ // 1. Convert literal \n sequences (escaped newlines from GitHub API) to actual newlines
125
+ // 2. Remove leading/trailing quotes (including escaped quotes from command-stream shell escaping)
126
+ // 3. Remove any trailing npm package links or markdown that might be there
127
+ // 4. Normalize whitespace while preserving line breaks
128
+ const cleanDescription = rawDescription
129
+ .replace(/\\n/g, '\n') // Convert escaped \n to actual newlines
130
+ .replace(/^(\\['"])+/g, '') // Remove leading escaped quotes (e.g., \', \", \'', \'')
131
+ .replace(/(['"])+$/g, '') // Remove trailing unescaped quotes (e.g., ', ", '', '')
132
+ .replace(/^(['"])+/g, '') // Remove leading unescaped quotes
133
+ .replace(/📦.*$/s, '') // Remove any existing npm package info
134
+ .replace(/---.*$/s, '') // Remove any existing separators and everything after
135
+ .trim()
136
+ .split('\n') // Split by lines
137
+ .map((line) => line.trim()) // Trim whitespace from each line
138
+ .join('\n') // Rejoin with newlines
139
+ .replace(/\n{3,}/g, '\n\n'); // Normalize excessive blank lines (3+ becomes 2)
140
+
141
+ // Find the PR that contains the release commit
142
+ // Uses commit hash from changelog or passed commit SHA from workflow
143
+ let prNumber = null;
144
+
145
+ // Determine which commit SHA to use for PR lookup
146
+ const commitShaToLookup = commitHash || passedCommitSha;
147
+
148
+ if (commitShaToLookup) {
149
+ const source = commitHash ? 'changelog' : 'workflow';
150
+ console.log(
151
+ `ℹ️ Looking up PR for commit ${commitShaToLookup} (from ${source})`
152
+ );
153
+
154
+ try {
155
+ const prResult =
156
+ await $`gh api "repos/${repository}/commits/${commitShaToLookup}/pulls"`.run(
157
+ { capture: true }
158
+ );
159
+ const prsData = JSON.parse(prResult.stdout);
160
+
161
+ // Find the PR that's not the version bump PR (not "chore: version packages")
162
+ const relevantPr = prsData.find(
163
+ (pr) => !pr.title.includes('version packages')
164
+ );
165
+
166
+ if (relevantPr) {
167
+ prNumber = relevantPr.number;
168
+ console.log(`✅ Found PR #${prNumber} containing commit`);
169
+ } else if (prsData.length > 0) {
170
+ console.log(
171
+ '⚠️ Found PRs but all are version bump PRs, not linking any'
172
+ );
173
+ } else {
174
+ console.log(
175
+ 'ℹ️ No PR found containing this commit - not adding PR link'
176
+ );
177
+ }
178
+ } catch (error) {
179
+ console.log('⚠️ Could not find PR for commit', commitShaToLookup);
180
+ console.log(' Error:', error.message);
181
+ if (process.env.DEBUG) {
182
+ console.error(error);
183
+ }
184
+ }
185
+ } else {
186
+ // No commit hash available from any source
187
+ console.log('ℹ️ No commit SHA available - not adding PR link');
188
+ }
189
+
190
+ // Build formatted release notes
191
+ const versionWithoutV = version.replace(/^v/, '');
192
+ const npmBadge = `[![npm version](https://img.shields.io/badge/npm-${versionWithoutV}-blue.svg)](https://www.npmjs.com/package/${PACKAGE_NAME}/v/${versionWithoutV})`;
193
+
194
+ let formattedBody = `${cleanDescription}`;
195
+
196
+ // Add PR link if available
197
+ if (prNumber) {
198
+ formattedBody += `\n\n**Related Pull Request:** #${prNumber}`;
199
+ }
200
+
201
+ formattedBody += `\n\n---\n\n${npmBadge}`;
202
+
203
+ // Update the release using JSON input to properly handle special characters
204
+ const updatePayload = JSON.stringify({ body: formattedBody });
205
+ await $`gh api repos/${repository}/releases/${releaseId} -X PATCH --input -`.run(
206
+ { stdin: updatePayload }
207
+ );
208
+
209
+ console.log(`✅ Formatted release notes for v${versionWithoutV}`);
210
+ if (prNumber) {
211
+ console.log(` - Added link to PR #${prNumber}`);
212
+ }
213
+ console.log(' - Added shields.io npm badge');
214
+ console.log(' - Cleaned up formatting');
215
+ } catch (error) {
216
+ console.error('❌ Error formatting release notes:', error.message);
217
+ process.exit(1);
218
+ }